From 2715880109fb0f84165124da01da8c9bb8787325 Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Tue, 16 Jul 2024 09:58:21 +0200 Subject: [PATCH 01/43] 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 99495f47cf55c8a2c9ea3448a229a81bbeeabf21 Mon Sep 17 00:00:00 2001 From: koviubi56 Date: Thu, 18 Jul 2024 14:54:47 +0200 Subject: [PATCH 02/43] Set up - Set up some pre-commit hooks (including mypy) - Add some ruff & mypy config - Remove the samples directory Signed-off-by: koviubi56 --- .pre-commit-config.yaml | 14 +++++++++++++- LICENSE.txt | 26 ++++++++++++++++++++++++++ pyproject.toml | 16 +++++++++++++--- samples/Pipfile | 15 --------------- samples/pyproject.toml | 19 ------------------- 5 files changed, 52 insertions(+), 38 deletions(-) delete mode 100644 samples/Pipfile delete mode 100644 samples/pyproject.toml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4bccb6f..f63a71f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,6 +5,11 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 hooks: + - id: check-added-large-files + - id: check-ast + - id: check-case-conflict + - id: check-illegal-windows-names + - id: check-json - id: check-toml - id: check-yaml - id: end-of-file-fixer @@ -12,7 +17,14 @@ repos: args: [--markdown-linebreak-ext=md] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.0 + rev: v0.5.2 hooks: - id: ruff + args: [--fix] - id: ruff-format + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.10.1 + hooks: + - id: mypy + # args: [--strict] # Put config in pyproject.toml. If it doesn't work, put it here. diff --git a/LICENSE.txt b/LICENSE.txt index 5a04926..d5f910f 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,3 +1,29 @@ +MIT License + +Copyright (c) 2024 Sincere Singularities + +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. + +--- + +The Software incorporates code from which is under the following license: + 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: diff --git a/pyproject.toml b/pyproject.toml index 0880be9..906475b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,13 +25,11 @@ ignore = [ "D107", # Docstring whitespace. "D203", - "D213", + "D212", # Docstring punctuation. "D415", # Docstring quotes. "D301", - # Builtins. - "A", # Print statements. "T20", # TODOs. @@ -41,4 +39,16 @@ ignore = [ # Annotations. "ANN101", "ANN102", + # Future annotations. + "FA", + # Error messages. + "EM", ] + +[tool.ruff.lint.pydocstyle] +# Use Google-style docstrings. +convention = "google" +ignore-decorators = ["typing.overload"] + +[tool.mypy] +strict = true 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 191a0139cb05c4a8b691b880dfcb9723830b686f Mon Sep 17 00:00:00 2001 From: koviubi56 Date: Thu, 18 Jul 2024 15:15:14 +0200 Subject: [PATCH 03/43] Fix pre-commit `check-illegal-windows-names` isn't released yet. Signed-off-by: koviubi56 --- .pre-commit-config.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f63a71f..58fa585 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,7 +8,6 @@ repos: - id: check-added-large-files - id: check-ast - id: check-case-conflict - - id: check-illegal-windows-names - id: check-json - id: check-toml - id: check-yaml From fa8c3bbd61b55da03b8c7867774b640c1b428a6f Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Sun, 21 Jul 2024 21:23:32 +0200 Subject: [PATCH 04/43] Basic Setup of Disnake - Setup first Basic ReadMe - Remove Docs from Dev Requirements - Add Requirements.txt (with Disnake Requirement) - Setup Basic Disnake Template main.py --- README.md | 214 ++++++------------------------------------- main.py | 27 ++++++ requirements-dev.txt | 2 - requirements.txt | 3 + 4 files changed, 60 insertions(+), 186 deletions(-) create mode 100644 main.py create mode 100644 requirements.txt diff --git a/README.md b/README.md index d50f7b7..e495427 100644 --- a/README.md +++ b/README.md @@ -1,184 +1,30 @@ -# 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! +**Sincere Singularities** Python Discord Summer CodeJam 2024 project. +The technology was **Discord Application**, our chosen framework was [Disnake](https://github.com/DisnakeDev/disnake/) and the theme **Information Overload** + +# Game Title Here + +--- + +Did you ever want to experience the information overload and stress of a phone operator? +Well, then you´re at the right place! + +We´ve created the first stressful Discord game, in which you play a phone operator for a restaurant. +No matter what dishes, customers, or special orders you have to serve, don't lose focus, or you might get overwhelmed by the information overload! + +## Table of contents +1. [Getting Started](https://github.com/Kingly-elpies/KinglyKelpies/blob/main/README.md#getting-started) + +## Getting Started +1. Python 3.11 is [recommended](https://github.com/DisnakeDev/disnake/pull/1135#issuecomment-1847303628). +2. Install the Game: + ```shell + $ git clone https://github.com/SincereSingularities/SincereSingularities/ + $ cd SincereSingularities + $ pip install -r requirements.txt + ``` +3. Setup a [Discord Bot](https://docs.disnake.dev/en/stable/discord.html). +4. Set the `BOT_TOKEN` environment variable to your Token. +5. Run The Game: + ```shell + $ python main.py + ``` diff --git a/main.py b/main.py new file mode 100644 index 0000000..93ca0d9 --- /dev/null +++ b/main.py @@ -0,0 +1,27 @@ +import os + +import disnake +from disnake.ext import commands + +TOKEN = os.getenv("BOT_TOKEN") + +intents = disnake.Intents.default() +bot = commands.Bot(command_prefix=commands.when_mentioned, intents=intents) + + +@bot.event # type: ignore[misc] +async def on_ready() -> None: + """ + Bot Information Logging when starting up. + + Returns: + None + """ + print( + f"Logged in as {bot.user} (ID: {bot.user.id}). \n" + f"Running on {len(bot.guilds)} servers with {bot.latency*1000:,.2f} ms latency.", + ) + + +if __name__ == "__main__": + bot.run(TOKEN) diff --git a/requirements-dev.txt b/requirements-dev.txt index d529f2e..4167392 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,4 @@ # 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/requirements.txt b/requirements.txt new file mode 100644 index 0000000..fbf7a0e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +# This file contains all the requirements needed to run the project. + +disnake~=2.9.2 From e4e6b1ea3696f3db95148f6707d43413a6cea8af Mon Sep 17 00:00:00 2001 From: koviubi56 Date: Mon, 22 Jul 2024 12:04:11 +0200 Subject: [PATCH 05/43] Package code and clean up (#3) * Package code and clean up - It's generally best practice to package python projects, so this commit does that using setuptools. - Clean up and update the README. - Use python-dotenv for loading .env variables at runtime - Clean up bot code Signed-off-by: koviubi56 * Format code Signed-off-by: koviubi56 * Remove 3.12 Signed-off-by: koviubi56 --------- Signed-off-by: koviubi56 --- .gitignore | 3 ++ README.md | 26 +++++++-------- main.py | 27 ---------------- pyproject.toml | 46 +++++++++++++++++++++++++++ requirements.txt | 1 + setup.py | 3 ++ src/sincere_singularities/__init__.py | 0 src/sincere_singularities/__main__.py | 16 ++++++++++ src/sincere_singularities/bot.py | 14 ++++++++ 9 files changed, 95 insertions(+), 41 deletions(-) delete mode 100644 main.py create mode 100644 setup.py create mode 100644 src/sincere_singularities/__init__.py create mode 100644 src/sincere_singularities/__main__.py create mode 100644 src/sincere_singularities/bot.py diff --git a/.gitignore b/.gitignore index 233eb87..2e5c2a6 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,9 @@ env dist/ build/ +# Distribution / packaging +*.egg-info/ + # IDEs # PyCharm .idea/ diff --git a/README.md b/README.md index e495427..6d6b2cc 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,28 @@ -**Sincere Singularities** Python Discord Summer CodeJam 2024 project. -The technology was **Discord Application**, our chosen framework was [Disnake](https://github.com/DisnakeDev/disnake/) and the theme **Information Overload** - # Game Title Here +**Sincere Singularities** Python Discord Summer CodeJam 2024 project. +The technology is **Discord Application**, the theme is **Information Overload**, and our chosen framework is [Disnake](https://github.com/DisnakeDev/disnake/). + --- Did you ever want to experience the information overload and stress of a phone operator? -Well, then you´re at the right place! +Well, then you're at the right place! -We´ve created the first stressful Discord game, in which you play a phone operator for a restaurant. +We've created a first stressful Discord game, in which you play a phone operator for a restaurant. No matter what dishes, customers, or special orders you have to serve, don't lose focus, or you might get overwhelmed by the information overload! -## Table of contents -1. [Getting Started](https://github.com/Kingly-elpies/KinglyKelpies/blob/main/README.md#getting-started) +## Running the bot -## Getting Started 1. Python 3.11 is [recommended](https://github.com/DisnakeDev/disnake/pull/1135#issuecomment-1847303628). 2. Install the Game: ```shell - $ git clone https://github.com/SincereSingularities/SincereSingularities/ - $ cd SincereSingularities - $ pip install -r requirements.txt + git clone https://github.com/SincereSingularities/SincereSingularities/ + cd SincereSingularities + pip install -e . ``` -3. Setup a [Discord Bot](https://docs.disnake.dev/en/stable/discord.html). -4. Set the `BOT_TOKEN` environment variable to your Token. +3. Setup a [Discord Bot](https://docs.disnake.dev/en/stable/discord.html). +4. Set the `BOT_TOKEN` environment variable to your Token using the `.env` file. 5. Run The Game: ```shell - $ python main.py + python -m sincere_singularities ``` diff --git a/main.py b/main.py deleted file mode 100644 index 93ca0d9..0000000 --- a/main.py +++ /dev/null @@ -1,27 +0,0 @@ -import os - -import disnake -from disnake.ext import commands - -TOKEN = os.getenv("BOT_TOKEN") - -intents = disnake.Intents.default() -bot = commands.Bot(command_prefix=commands.when_mentioned, intents=intents) - - -@bot.event # type: ignore[misc] -async def on_ready() -> None: - """ - Bot Information Logging when starting up. - - Returns: - None - """ - print( - f"Logged in as {bot.user} (ID: {bot.user.id}). \n" - f"Running on {len(bot.guilds)} servers with {bot.latency*1000:,.2f} ms latency.", - ) - - -if __name__ == "__main__": - bot.run(TOKEN) diff --git a/pyproject.toml b/pyproject.toml index 906475b..e8414c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,48 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "sincere_singularities" +version = "1.0.0" # no need to change the version +description = "Sincere Singularities Python Discord Summer CodeJam 2024 project." +readme = "README.md" +requires-python = ">=3.11" +license = { text = "MIT" } +authors = [ + { name = "clucker-m8" }, + { name = "FoxFil" }, + { name = "koviubi56" }, + { name = "Vinyzu" }, + { name = "WassCodeur" }, +] +classifiers = [ + "License :: OSI Approved :: MIT License", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: Implementation :: CPython", + "Topic :: Games/Entertainment", + "Typing :: Typed", +] +dynamic = ["dependencies", "optional-dependencies"] + +[tool.setuptools.dynamic] +dependencies = { file = "requirements.txt" } +optional-dependencies.dev = { file = ["requirements-dev.txt"] } + +[tool.setuptools.package-data] +sincere_singularities = ["py.typed"] + +[project.urls] +Homepage = "https://github.com/SincereSingularities/SincereSingularities" + +[project.scripts] +sincere_singularities = "sincere_singularities.__main__:main" + [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, @@ -52,3 +97,4 @@ ignore-decorators = ["typing.overload"] [tool.mypy] strict = true +disallow_untyped_decorators = false diff --git a/requirements.txt b/requirements.txt index fbf7a0e..d93df9f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ # This file contains all the requirements needed to run the project. disnake~=2.9.2 +python-dotenv~=1.0.1 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..6068493 --- /dev/null +++ b/setup.py @@ -0,0 +1,3 @@ +from setuptools import setup + +setup() diff --git a/src/sincere_singularities/__init__.py b/src/sincere_singularities/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sincere_singularities/__main__.py b/src/sincere_singularities/__main__.py new file mode 100644 index 0000000..407d61b --- /dev/null +++ b/src/sincere_singularities/__main__.py @@ -0,0 +1,16 @@ +import os + +import dotenv + +from sincere_singularities.bot import bot + + +def main() -> None: + """Load .env, and run the bot.""" + dotenv.load_dotenv() + token = os.getenv("BOT_TOKEN") + bot.run(token) + + +if __name__ == "__main__": + main() diff --git a/src/sincere_singularities/bot.py b/src/sincere_singularities/bot.py new file mode 100644 index 0000000..90a15ce --- /dev/null +++ b/src/sincere_singularities/bot.py @@ -0,0 +1,14 @@ +import disnake +from disnake.ext import commands + +intents = disnake.Intents.default() +bot = commands.InteractionBot(intents=intents) + + +@bot.event +async def on_ready() -> None: + """Bot information logging when starting up.""" + print( + f"Logged in as {bot.user} (ID: {bot.user.id}).\n" + f"Running on {len(bot.guilds)} servers with {bot.latency*1000:,.2f} ms latency.", + ) From 3344b7b58fe24a76a9f30982937f1b7a9bb5f314 Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Mon, 22 Jul 2024 19:12:35 +0200 Subject: [PATCH 06/43] Restaurant Chooser View (#4) * Restaurant Chooser View - Implemented /start_game command - Implemented First Basic (template) restaurants.json - Implemented Restaurants Chooser View - Implemented Basic Restaurant Class TODOS: - Add Docstrings/Comments - Refine Restaurants Embed - Implement Restaurant Menu - (Add Typing Hint) * Fix Typing / Linting Issues * Fixed Reviews --- .github/workflows/lint.yaml | 2 +- pyproject.toml | 7 ++ requirements.txt | 1 + src/sincere_singularities/bot.py | 10 +++ .../data/restaurants.json | 26 ++++++ src/sincere_singularities/restaurant.py | 21 +++++ src/sincere_singularities/restaurants_view.py | 87 +++++++++++++++++++ src/sincere_singularities/utils.py | 42 +++++++++ 8 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 src/sincere_singularities/data/restaurants.json create mode 100644 src/sincere_singularities/restaurant.py create mode 100644 src/sincere_singularities/restaurants_view.py create mode 100644 src/sincere_singularities/utils.py diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 7f67e80..335a9bc 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -20,7 +20,7 @@ jobs: env: # The Python version your project uses. Feel free to change this if required. - PYTHON_VERSION: "3.12" + PYTHON_VERSION: "3.11" steps: - name: Checkout repository diff --git a/pyproject.toml b/pyproject.toml index e8414c8..74f7b42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,6 +88,13 @@ ignore = [ "FA", # Error messages. "EM", + # Type Keyword (3.12+) + "UP040", + # Exceptions + "TRY003", + # Recommended by Ruff + "ISC001", + "COM812" ] [tool.ruff.lint.pydocstyle] diff --git a/requirements.txt b/requirements.txt index d93df9f..dd54fb4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ disnake~=2.9.2 python-dotenv~=1.0.1 +dacite~=1.8.1 diff --git a/src/sincere_singularities/bot.py b/src/sincere_singularities/bot.py index 90a15ce..67b78c5 100644 --- a/src/sincere_singularities/bot.py +++ b/src/sincere_singularities/bot.py @@ -1,10 +1,20 @@ import disnake from disnake.ext import commands +from sincere_singularities.restaurants_view import Restaurants + intents = disnake.Intents.default() bot = commands.InteractionBot(intents=intents) +@bot.slash_command(name="start_game") +async def start_game(inter: disnake.ApplicationCommandInteraction) -> None: + """Main Command of our Game: /start_game""" + # Load Restaurants + restaurants = Restaurants(inter) + await inter.response.send_message(embed=restaurants.embeds[0], view=restaurants.view, ephemeral=True) + + @bot.event async def on_ready() -> None: """Bot information logging when starting up.""" diff --git a/src/sincere_singularities/data/restaurants.json b/src/sincere_singularities/data/restaurants.json new file mode 100644 index 0000000..d9c89de --- /dev/null +++ b/src/sincere_singularities/data/restaurants.json @@ -0,0 +1,26 @@ +[ + { + "name": "Pizzaria", + "icon": ":pizza:", + "description": "Pizzaria Placeholder Description", + "color": "red", + "menu": { + "Starters": [], + "Main Courses": [], + "Desserts": [], + "Drinks": [] + } + }, + { + "name": "Pizzaria Test1", + "icon": ":pizza:", + "description": "Pizzaria Placeholder Description", + "color": "red", + "menu": { + "Starters": [], + "Main Courses": [], + "Desserts": [], + "Drinks": [] + } + } +] diff --git a/src/sincere_singularities/restaurant.py b/src/sincere_singularities/restaurant.py new file mode 100644 index 0000000..3d602f0 --- /dev/null +++ b/src/sincere_singularities/restaurant.py @@ -0,0 +1,21 @@ +from disnake import MessageInteraction + +from sincere_singularities.utils import RestaurantJson + + +class Restaurant: + """Represents a single restaurant.""" + + def __init__(self, restaurant_json: RestaurantJson) -> None: + self.restaurant_json = restaurant_json + + self.name = restaurant_json.name + + async def enter_menu(self, inter: MessageInteraction) -> None: + """ + Function Called initially when the user enters the restaurant + + Args: + inter: The Disnake MessageInteraction object. + """ + await inter.response.send_message(f"Restaurant {self.name} is entering menu") diff --git a/src/sincere_singularities/restaurants_view.py b/src/sincere_singularities/restaurants_view.py new file mode 100644 index 0000000..802274b --- /dev/null +++ b/src/sincere_singularities/restaurants_view.py @@ -0,0 +1,87 @@ +import disnake + +from sincere_singularities.restaurant import Restaurant +from sincere_singularities.utils import RestaurantJsonType, load_json + +DISNAKE_COLORS = { + "red": disnake.Colour.red(), +} + + +class RestaurantsView(disnake.ui.View): # type: ignore[misc] + """View Subclass for Choosing the Restaurant""" + + def __init__(self, ctx: "Restaurants", embeds: list[disnake.Embed]) -> None: + super().__init__(timeout=None) + self.ctx = ctx + self.embeds = embeds + self.index = 0 + + # Sets the footer of the embeds with their respective page numbers. + for i, embed in enumerate(self.embeds): + embed.set_footer(text=f"Restaurant {i + 1} of {len(self.embeds)}") + + self._update_state() + + def _update_state(self) -> None: + self._prev_page.disabled = self.index == 0 + self._next_page.disabled = self.index == len(self.embeds) - 1 + + @disnake.ui.button(emoji="◀", style=disnake.ButtonStyle.secondary) + async def _prev_page(self, _: disnake.ui.Button, inter: disnake.MessageInteraction) -> None: + self.index -= 1 + self._update_state() + + await inter.response.edit_message(embed=self.embeds[self.index], view=self) + + @disnake.ui.button(label="Enter Restaurant", style=disnake.ButtonStyle.green) + async def _enter_restaurant(self, _: disnake.ui.Button, inter: disnake.MessageInteraction) -> None: + # Enter Restaurant based on current index + restaurant = self.ctx.restaurants[self.index] + await restaurant.enter_menu(inter) + + @disnake.ui.button(emoji="▶", style=disnake.ButtonStyle.secondary) + async def _next_page(self, _: disnake.ui.Button, inter: disnake.MessageInteraction) -> None: + self.index += 1 + self._update_state() + + await inter.response.edit_message(embed=self.embeds[self.index], view=self) + + +class Restaurants: + """Class to Manage the Restaurants & UI""" + + def __init__(self, inter: disnake.ApplicationCommandInteraction) -> None: + self.inter = inter + # Loading Restaurants + self.restaurants_json = load_json("restaurants.json", RestaurantJsonType) + self.view = RestaurantsView(self, self.embeds) + + @property + def embeds(self) -> list[disnake.Embed]: + """ + Getting the Embeds of each Restaurant (On the Restaurant Selection Screen). + + Returns: List of Disnake Embeds + + """ + # Generate Embeds from Restaurants + return [ + disnake.Embed( + title=restaurant.name, + description=restaurant.description, + colour=DISNAKE_COLORS.get(restaurant.color, disnake.Color.random()), + ) + for restaurant in self.restaurants_json + ] + + @property + def restaurants(self) -> list[Restaurant]: + """ + Getting the Restaurants List, each Restaurant is initialized via its JSON. + + Returns: List of Restaurant Classes + + """ + # Creating Restaurant Objects Based on the Data + return [Restaurant(restaurant) for restaurant in self.restaurants_json] diff --git a/src/sincere_singularities/utils.py b/src/sincere_singularities/utils.py new file mode 100644 index 0000000..17df2db --- /dev/null +++ b/src/sincere_singularities/utils.py @@ -0,0 +1,42 @@ +import json +from dataclasses import dataclass +from pathlib import Path +from typing import TypeAlias, TypeVar + +import dacite + +CURRENT_DIR = Path(__file__).parent.absolute() + + +@dataclass(unsafe_hash=True) +class RestaurantJson: + """Represents a JSON-like object representing a Restaurant.""" + + name: str + icon: str + description: str + color: str + menu: dict[str, list[str]] + + +RestaurantJsonType: TypeAlias = list[RestaurantJson] +T = TypeVar("T", bound=RestaurantJsonType) + + +def load_json(filename: str, json_type: type[T]) -> T: + """ + Helper for Loading a Json File + + Args: + filename: Filename, e.g. myfile.json + json_type: Json Type + + Returns: JsonType + + """ + json_filepath = CURRENT_DIR / "data" / filename + with json_filepath.open(mode="r", encoding="utf-8") as f: + loaded_json = json.load(f) + typed_json: T = dacite.from_dict(json_type, loaded_json) + + return typed_json From 4bb742c57e461c0a25b11ec5355eb2dee478a34a Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Mon, 22 Jul 2024 20:51:49 +0200 Subject: [PATCH 07/43] Restaurant Select Improvements (#5) - Implemented Custom Colors (Based on Icon) - Added some Template Menu Items - Added a Example Items Section to the Restaurant overview - Added Proper Type Handling when Loading the JSON --- pyproject.toml | 4 +- .../data/restaurants.json | 24 +++++----- src/sincere_singularities/restaurant.py | 2 + src/sincere_singularities/restaurants_view.py | 46 +++++++++++++++---- src/sincere_singularities/utils.py | 15 +++++- 5 files changed, 67 insertions(+), 24 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 74f7b42..c95253a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,7 +94,9 @@ ignore = [ "TRY003", # Recommended by Ruff "ISC001", - "COM812" + "COM812", + # Pseudo-Random Generators + "S311" ] [tool.ruff.lint.pydocstyle] diff --git a/src/sincere_singularities/data/restaurants.json b/src/sincere_singularities/data/restaurants.json index d9c89de..017867e 100644 --- a/src/sincere_singularities/data/restaurants.json +++ b/src/sincere_singularities/data/restaurants.json @@ -3,24 +3,22 @@ "name": "Pizzaria", "icon": ":pizza:", "description": "Pizzaria Placeholder Description", - "color": "red", "menu": { - "Starters": [], - "Main Courses": [], - "Desserts": [], - "Drinks": [] + "Starters": ["Pizza Starter0", "Pizza Starter1"], + "Main Courses": ["Main Course0", "Pizza Main Course1"], + "Desserts": ["Pizza Dessert0", "Pizza Dessert1"], + "Drinks": ["Pizza Drink0", "Pizza Drink1"] } }, { - "name": "Pizzaria Test1", - "icon": ":pizza:", - "description": "Pizzaria Placeholder Description", - "color": "red", + "name": "Sushi Restaurant", + "icon": ":sushi:", + "description": "Sushi Placeholder Description", "menu": { - "Starters": [], - "Main Courses": [], - "Desserts": [], - "Drinks": [] + "Starters": ["Sushi Starter0", "Sushi Starter1"], + "Main Courses": ["Sushi Main Course0", "Sushi Main Course1"], + "Desserts": ["Sushi Dessert0", "Sushi Dessert1"], + "Drinks": ["Drink0", "Drink1"] } } ] diff --git a/src/sincere_singularities/restaurant.py b/src/sincere_singularities/restaurant.py index 3d602f0..206cbe3 100644 --- a/src/sincere_singularities/restaurant.py +++ b/src/sincere_singularities/restaurant.py @@ -10,6 +10,8 @@ def __init__(self, restaurant_json: RestaurantJson) -> None: self.restaurant_json = restaurant_json self.name = restaurant_json.name + self.icon = restaurant_json.icon + self.description = restaurant_json.description async def enter_menu(self, inter: MessageInteraction) -> None: """ diff --git a/src/sincere_singularities/restaurants_view.py b/src/sincere_singularities/restaurants_view.py index 802274b..4b4cef2 100644 --- a/src/sincere_singularities/restaurants_view.py +++ b/src/sincere_singularities/restaurants_view.py @@ -1,10 +1,13 @@ +import random + import disnake from sincere_singularities.restaurant import Restaurant from sincere_singularities.utils import RestaurantJsonType, load_json DISNAKE_COLORS = { - "red": disnake.Colour.red(), + ":pizza:": disnake.Color.from_rgb(229, 97, 38), + ":sushi:": disnake.Color.from_rgb(255, 153, 153), } @@ -66,14 +69,41 @@ def embeds(self) -> list[disnake.Embed]: """ # Generate Embeds from Restaurants - return [ - disnake.Embed( - title=restaurant.name, - description=restaurant.description, - colour=DISNAKE_COLORS.get(restaurant.color, disnake.Color.random()), + embeds = [] + + for restaurant in self.restaurants_json: + embed = disnake.Embed( + title=f"{restaurant.icon} {restaurant.name} {restaurant.icon}", + description=f"{restaurant.description} \n", + colour=DISNAKE_COLORS.get(restaurant.icon, disnake.Color.random()), + ) + # Adding an Empty Field for better formatting + embed.add_field(" ", " ") + # Adding Examples from the Menu + embed.add_field( + name="Example Starter", + value=f"`{random.choice(restaurant.menu['Starters'])}`", + inline=False, + ) + embed.add_field( + name="Example Main Course", + value=f"`{random.choice(restaurant.menu['Main Courses'])}`", + inline=False, ) - for restaurant in self.restaurants_json - ] + embed.add_field( + name="Example Dessert", + value=f"`{random.choice(restaurant.menu['Desserts'])}`", + inline=False, + ) + embed.add_field( + name="Example Drink", + value=f"`{random.choice(restaurant.menu['Drinks'])}`", + inline=False, + ) + + embeds.append(embed) + + return embeds @property def restaurants(self) -> list[Restaurant]: diff --git a/src/sincere_singularities/utils.py b/src/sincere_singularities/utils.py index 17df2db..c6b9fb4 100644 --- a/src/sincere_singularities/utils.py +++ b/src/sincere_singularities/utils.py @@ -1,7 +1,7 @@ import json from dataclasses import dataclass from pathlib import Path -from typing import TypeAlias, TypeVar +from typing import TypeAlias, TypeVar, get_args, get_origin import dacite @@ -15,7 +15,6 @@ class RestaurantJson: name: str icon: str description: str - color: str menu: dict[str, list[str]] @@ -34,9 +33,21 @@ def load_json(filename: str, json_type: type[T]) -> T: Returns: JsonType """ + # Opening the FilePath which is found under ./data/... json_filepath = CURRENT_DIR / "data" / filename with json_filepath.open(mode="r", encoding="utf-8") as f: loaded_json = json.load(f) + + if isinstance(loaded_json, list) and get_origin(json_type) is list: + # This gets the Class Type of the first element of json_type + # Note: We can assume the json_type list only has one element + obj_type = get_args(json_type)[0] + + # Applying the Dataclass to every Object in the List. + typed_objs: T = [dacite.from_dict(obj_type, obj) for obj in loaded_json] # type: ignore[assignment] + return typed_objs + + # Applying the Dataclass to the loaded_json typed_json: T = dacite.from_dict(json_type, loaded_json) return typed_json From 5e6412c4bc1ea317dcf67cec4d34d5ae87046a70 Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Wed, 24 Jul 2024 11:53:30 +0200 Subject: [PATCH 08/43] GUI based Orders are almost done. (#8) * GUI based Orders are almost done. - Refractored some of the Code (Structure) - Added Ordering Menu Selection Screen - Added Order Check - Implemented better Colors Co-Authored-By: koviubi56 * Fix Linting Issues Co-Authored-By: koviubi56 * Remove Unused Type: Ignore Co-Authored-By: koviubi56 * Fix Formatting Co-Authored-By: koviubi56 * Simplify List Comprehension Co-authored-by: koviubi56 * Update CustomerInfo Dataclass Infrastructure Note: We need frozen=True to make the dataclass hashable to use comparisons. Co-Authored-By: koviubi56 * Fix Formatting --------- Co-authored-by: koviubi56 --- pyproject.toml | 7 +- src/sincere_singularities/bot.py | 2 +- src/sincere_singularities/modules/__init__.py | 0 src/sincere_singularities/modules/order.py | 167 ++++++++++++++++++ .../modules/restaurant.py | 108 +++++++++++ .../{ => modules}/restaurants_view.py | 47 +++-- .../modules/sentiment_analysis.py | 13 ++ src/sincere_singularities/restaurant.py | 23 --- src/sincere_singularities/utils.py | 6 + 9 files changed, 334 insertions(+), 39 deletions(-) create mode 100644 src/sincere_singularities/modules/__init__.py create mode 100644 src/sincere_singularities/modules/order.py create mode 100644 src/sincere_singularities/modules/restaurant.py rename src/sincere_singularities/{ => modules}/restaurants_view.py (73%) create mode 100644 src/sincere_singularities/modules/sentiment_analysis.py delete mode 100644 src/sincere_singularities/restaurant.py diff --git a/pyproject.toml b/pyproject.toml index c95253a..b4698bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,7 +96,11 @@ ignore = [ "ISC001", "COM812", # Pseudo-Random Generators - "S311" + "S311", + # Asserts + "S101", + # Function Arguments + "PLR0913" ] [tool.ruff.lint.pydocstyle] @@ -107,3 +111,4 @@ ignore-decorators = ["typing.overload"] [tool.mypy] strict = true disallow_untyped_decorators = false +disallow_subclassing_any = false diff --git a/src/sincere_singularities/bot.py b/src/sincere_singularities/bot.py index 67b78c5..932e381 100644 --- a/src/sincere_singularities/bot.py +++ b/src/sincere_singularities/bot.py @@ -1,7 +1,7 @@ import disnake from disnake.ext import commands -from sincere_singularities.restaurants_view import Restaurants +from sincere_singularities.modules.restaurants_view import Restaurants intents = disnake.Intents.default() bot = commands.InteractionBot(intents=intents) diff --git a/src/sincere_singularities/modules/__init__.py b/src/sincere_singularities/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sincere_singularities/modules/order.py b/src/sincere_singularities/modules/order.py new file mode 100644 index 0000000..7310ba1 --- /dev/null +++ b/src/sincere_singularities/modules/order.py @@ -0,0 +1,167 @@ +from collections import defaultdict +from collections.abc import Iterable +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +import disnake +from disnake import ButtonStyle, MessageInteraction, ModalInteraction, TextInputStyle + +from sincere_singularities.utils import DISNAKE_COLORS + +if TYPE_CHECKING: + from sincere_singularities.modules.restaurant import Restaurant + + +@dataclass(frozen=True) +class CustomerInfo: + """The Dataclass Containing Information added in the Customer Information Section.""" + + name: str + address: str + delivery_time: str + extra_information: str + + +@dataclass(unsafe_hash=True) +class Order: + """The Dataclass Containing Order Information.""" + + customer_information: CustomerInfo | None = None + foods: defaultdict[str, list[str]] = field(default_factory=lambda: defaultdict(list[str])) + + +class CustomerInfoModal(disnake.ui.Modal): + """The Modal for entering Customer Information.""" + + def __init__(self, order_view: "OrderView", order: Order) -> None: + self.order_view = order_view + self.order = order + components = [ + disnake.ui.TextInput(label="Name", custom_id="name", style=TextInputStyle.short, max_length=64), + disnake.ui.TextInput(label="Address", custom_id="address", style=TextInputStyle.short, max_length=64), + disnake.ui.TextInput( + label="Time of delivery", custom_id="time", style=TextInputStyle.short, required=False, max_length=64 + ), + disnake.ui.TextInput( + label="Extra information", + custom_id="extra", + style=TextInputStyle.paragraph, + required=False, + max_length=1028, + ), + ] + super().__init__(title="Customer information", components=components) + + async def callback(self, inter: ModalInteraction) -> None: + """The Callback when the User has entered the Customer Information.""" + self.order.customer_information = CustomerInfo( + name=inter.text_values["name"], + address=inter.text_values["address"], + delivery_time=inter.text_values["time"], + extra_information=inter.text_values["extra"], + ) + await inter.response.edit_message(view=self.order_view, embed=self.order_view.embed) + + +class FoodButton(disnake.ui.Button): + """A Button for adding a specific Menu Item.""" + + def __init__(self, food_view: "FoodsView", order: Order, menu_item: str, food: str) -> None: + super().__init__(label=food, style=ButtonStyle.primary) + self.food_view = food_view + self.order = order + self.menu_item = menu_item + self.food = food + + async def callback(self, inter: MessageInteraction) -> None: + """The Callback after adding a specific Menu Item.""" + self.order.foods[self.menu_item].append(self.food) + await inter.response.edit_message(view=self.food_view, embed=self.food_view.order_view.embed) + + +class FoodsView(disnake.ui.View): + """The View for adding a Menu Items (e.g. Main Courses).""" + + def __init__( + self, restaurant: "Restaurant", order_view: "OrderView", order: Order, menu_item: str, foods: Iterable[str] + ) -> None: + super().__init__() + self.restaurant = restaurant + self.order_view = order_view + self.order = order + self.menu_item = menu_item + for food in foods: + self.add_item(FoodButton(self, self.order, self.menu_item, food)) + + @disnake.ui.button(label="Back", style=disnake.ButtonStyle.secondary, row=1) + async def _food_back(self, _: disnake.ui.Button, inter: disnake.MessageInteraction) -> None: + await inter.response.edit_message(view=self.order_view, embed=self.order_view.embed) + + +class MenuItemButton(disnake.ui.Button): + """The Button for accessing a Menu Part (e.g. Main Courses)""" + + def __init__( + self, restaurant: "Restaurant", order_view: "OrderView", order: Order, menu_item: str, btn_index: int + ) -> None: + row_index = 0 if btn_index <= 1 else 1 + super().__init__(label=menu_item, style=ButtonStyle.primary, row=row_index, custom_id=menu_item) + self.restaurant = restaurant + self.order_view = order_view + self.order = order + self.menu_item = menu_item + + async def callback(self, inter: MessageInteraction) -> None: + """The Callback to access a Menu Part.""" + food_view = FoodsView( + self.restaurant, + self.order_view, + self.order, + self.menu_item, + self.restaurant.restaurant_json.menu[self.menu_item], + ) + await inter.response.edit_message(view=food_view, embed=self.order_view.embed) + + +class OrderView(disnake.ui.View): + """The view in which the order is displayed and managed by the user.""" + + def __init__(self, restaurant: "Restaurant") -> None: + super().__init__() + self.restaurant = restaurant + self.order = self.restaurant.order + for i, menu_item in enumerate(restaurant.menu): + self.add_item(MenuItemButton(restaurant, self, self.order, menu_item, i)) + + @property + def embed(self) -> disnake.Embed: + """Generating a new dynamic Embed for the Order Overview.""" + embed = disnake.Embed( + title=f"{self.restaurant.icon} MENU: {self.restaurant.name} {self.restaurant.icon}", + description=f"{self.restaurant.description} \n", + colour=DISNAKE_COLORS.get(self.restaurant.icon, disnake.Color.random()), + ) + # Adding an Empty Field for better formatting + embed.add_field(" ", " ") + # Adding Already Added Menu Items + for menu_name, menu_items in self.order.foods.items(): + embed.add_field( + name=f"Added {menu_name} Items", + value="\n".join(f"- `{item_name}`: {menu_items.count(item_name)}" for item_name in set(menu_items)), + inline=False, + ) + + return embed + + @disnake.ui.button(label="Customer Information", style=disnake.ButtonStyle.success, row=2) + async def _customer_information(self, _: disnake.ui.Button, inter: disnake.MessageInteraction) -> None: + await inter.response.send_modal(CustomerInfoModal(self, self.order)) + + @disnake.ui.button(label="Done", style=disnake.ButtonStyle.success, row=2) + async def _order_done(self, _: disnake.ui.Button, inter: disnake.MessageInteraction) -> None: + # Sending Order Placed Message and back to Start Screen + await inter.response.edit_message( + "Order placed successfully!", + embed=self.restaurant.restaurants.embeds[0], + view=self.restaurant.restaurants.view, + ) diff --git a/src/sincere_singularities/modules/restaurant.py b/src/sincere_singularities/modules/restaurant.py new file mode 100644 index 0000000..15c98f6 --- /dev/null +++ b/src/sincere_singularities/modules/restaurant.py @@ -0,0 +1,108 @@ +from collections import Counter +from typing import TYPE_CHECKING + +from disnake import MessageInteraction + +from sincere_singularities.modules.order import Order, OrderView +from sincere_singularities.modules.sentiment_analysis import check_content +from sincere_singularities.utils import RestaurantJson + +if TYPE_CHECKING: + from sincere_singularities.modules.restaurants_view import Restaurants + + +def count_differences(list0: list[str], list1: list[str]) -> int: + """ + Count the Differences between two lists, indexes independent. + + Args: + list0: First List to check. + list1: Second List to check. + + Returns: + The Amount of Differences. + """ + # Initialize Counters on the Lists + counter0 = Counter(list0) + counter1 = Counter(list1) + + # Calculate the total differences + return sum((counter0 - counter1).values()) + sum((counter1 - counter0).values()) + + +class Restaurant: + """Represents a single restaurant.""" + + def __init__(self, restaurants: "Restaurants", restaurant_json: RestaurantJson) -> None: + self.restaurants = restaurants + self.restaurant_json = restaurant_json + + self.name = restaurant_json.name + self.icon = restaurant_json.icon + self.description = restaurant_json.description + self.menu = restaurant_json.menu + + # Order Related + self.order = Order() + + async def enter_menu(self, inter: MessageInteraction) -> None: + """ + Function Called initially when the user enters the restaurant + + Args: + inter: The Disnake MessageInteraction object. + """ + view = OrderView(self) + await inter.response.edit_message(embed=view.embed, view=view) + + def check_order(self, correct_order: Order) -> float: + """ + Checking if the order was correctly placed by the user. + + Args: + correct_order: The Correct Order to check against. + + Returns: + How correct the order was placed in percentage (as a float) + """ + score = 1.0 + # The effect on the Score each wrong answer should have (Length of Menu Items + Customer Information Items) + score_percentile = 1 / (len(correct_order.foods) + 4) + + # Subtracting Sentiment Analysis Scores of the Customer Information + # This is achieved using a linear interpolation, meaning if the check gives 1.0, 0.0 will be subtracted from + # the score, but when the check gives 0.0, score_percentile will be subtracted + correct_customer_information = correct_order.customer_information + assert correct_customer_information + customer_information = self.order.customer_information + assert customer_information # TODO: blame the user for not filling out the Customer Information + + # Customer Name + name_check = check_content(correct_customer_information.address, customer_information.address) + score -= score_percentile + (-score_percentile * name_check) + + # Customer Address + address_check = check_content(correct_customer_information.address, customer_information.address) + score -= score_percentile + (-score_percentile * address_check) + + # Delivery Time + delivery_time_check = check_content( + correct_customer_information.delivery_time, customer_information.delivery_time + ) + score -= score_percentile + (-score_percentile * delivery_time_check) + + # Extra Information + extra_info_check = check_content( + correct_customer_information.extra_information, customer_information.extra_information + ) + score -= score_percentile + (-score_percentile * extra_info_check) + + # Now we can subtract score points for each wrong order + # Getting every order item + correct_order_items = [item for menu_items in correct_order.foods.values() for item in menu_items] + all_order_items = [item for menu_items in self.order.foods.values() for item in menu_items] + # Finding Differences between Orders and subtracting from Score + order_differences = count_differences(correct_order_items, all_order_items) + score -= score_percentile * order_differences + + return score diff --git a/src/sincere_singularities/restaurants_view.py b/src/sincere_singularities/modules/restaurants_view.py similarity index 73% rename from src/sincere_singularities/restaurants_view.py rename to src/sincere_singularities/modules/restaurants_view.py index 4b4cef2..306619c 100644 --- a/src/sincere_singularities/restaurants_view.py +++ b/src/sincere_singularities/modules/restaurants_view.py @@ -2,16 +2,11 @@ import disnake -from sincere_singularities.restaurant import Restaurant -from sincere_singularities.utils import RestaurantJsonType, load_json +from sincere_singularities.modules.restaurant import Restaurant +from sincere_singularities.utils import DISNAKE_COLORS, RestaurantJsonType, load_json -DISNAKE_COLORS = { - ":pizza:": disnake.Color.from_rgb(229, 97, 38), - ":sushi:": disnake.Color.from_rgb(255, 153, 153), -} - -class RestaurantsView(disnake.ui.View): # type: ignore[misc] +class RestaurantsView(disnake.ui.View): """View Subclass for Choosing the Restaurant""" def __init__(self, ctx: "Restaurants", embeds: list[disnake.Embed]) -> None: @@ -30,26 +25,40 @@ def _update_state(self) -> None: self._prev_page.disabled = self.index == 0 self._next_page.disabled = self.index == len(self.embeds) - 1 - @disnake.ui.button(emoji="◀", style=disnake.ButtonStyle.secondary) + @disnake.ui.button(emoji="◀", style=disnake.ButtonStyle.secondary, row=0) async def _prev_page(self, _: disnake.ui.Button, inter: disnake.MessageInteraction) -> None: self.index -= 1 self._update_state() await inter.response.edit_message(embed=self.embeds[self.index], view=self) - @disnake.ui.button(label="Enter Restaurant", style=disnake.ButtonStyle.green) + @disnake.ui.button(label="Enter Restaurant", style=disnake.ButtonStyle.success, row=0) async def _enter_restaurant(self, _: disnake.ui.Button, inter: disnake.MessageInteraction) -> None: + # Stopping view + self.stop() # Enter Restaurant based on current index restaurant = self.ctx.restaurants[self.index] await restaurant.enter_menu(inter) - @disnake.ui.button(emoji="▶", style=disnake.ButtonStyle.secondary) + @disnake.ui.button(emoji="▶", style=disnake.ButtonStyle.secondary, row=0) async def _next_page(self, _: disnake.ui.Button, inter: disnake.MessageInteraction) -> None: self.index += 1 self._update_state() await inter.response.edit_message(embed=self.embeds[self.index], view=self) + @disnake.ui.button(label="Pause Orders", style=disnake.ButtonStyle.secondary, row=1) + async def _pause_orders(self, *_: disnake.ui.Button | disnake.MessageInteraction) -> None: + # TODO: Pause Orders + # TODO: Fix awful typing when implemented + return + + @disnake.ui.button(label="Stop the Game", style=disnake.ButtonStyle.danger, row=1) + async def _stop_game(self, *_: disnake.ui.Button | disnake.MessageInteraction) -> None: + # TODO: Savestates? + # TODO: fix awful typing when implemented + await self.ctx.inter.delete_original_message() + class Restaurants: """Class to Manage the Restaurants & UI""" @@ -58,14 +67,24 @@ def __init__(self, inter: disnake.ApplicationCommandInteraction) -> None: self.inter = inter # Loading Restaurants self.restaurants_json = load_json("restaurants.json", RestaurantJsonType) - self.view = RestaurantsView(self, self.embeds) + + @property + def view(self) -> RestaurantsView: + """ + Getting the View Object for the Restaurant. Method to reload the View everytime. + + Returns: + RestaurantsView: The View Object + """ + return RestaurantsView(self, self.embeds) @property def embeds(self) -> list[disnake.Embed]: """ Getting the Embeds of each Restaurant (On the Restaurant Selection Screen). - Returns: List of Disnake Embeds + Returns: + List of Disnake Embeds """ # Generate Embeds from Restaurants @@ -114,4 +133,4 @@ def restaurants(self) -> list[Restaurant]: """ # Creating Restaurant Objects Based on the Data - return [Restaurant(restaurant) for restaurant in self.restaurants_json] + return [Restaurant(self, restaurant) for restaurant in self.restaurants_json] diff --git a/src/sincere_singularities/modules/sentiment_analysis.py b/src/sincere_singularities/modules/sentiment_analysis.py new file mode 100644 index 0000000..2eae825 --- /dev/null +++ b/src/sincere_singularities/modules/sentiment_analysis.py @@ -0,0 +1,13 @@ +def check_content(obj1: str, obj2: str) -> float: + """ + Checking if the content of two objects is roughly same using sentiment analysis. + + Args: + obj1: First Object to Check. + obj2: Second Object to Check. + + Returns: Similarity of two objects in percentage (as a float). + + """ + # TODO: Implement Sentiment Analysis + return float(obj1 == obj2) diff --git a/src/sincere_singularities/restaurant.py b/src/sincere_singularities/restaurant.py deleted file mode 100644 index 206cbe3..0000000 --- a/src/sincere_singularities/restaurant.py +++ /dev/null @@ -1,23 +0,0 @@ -from disnake import MessageInteraction - -from sincere_singularities.utils import RestaurantJson - - -class Restaurant: - """Represents a single restaurant.""" - - def __init__(self, restaurant_json: RestaurantJson) -> None: - self.restaurant_json = restaurant_json - - self.name = restaurant_json.name - self.icon = restaurant_json.icon - self.description = restaurant_json.description - - async def enter_menu(self, inter: MessageInteraction) -> None: - """ - Function Called initially when the user enters the restaurant - - Args: - inter: The Disnake MessageInteraction object. - """ - await inter.response.send_message(f"Restaurant {self.name} is entering menu") diff --git a/src/sincere_singularities/utils.py b/src/sincere_singularities/utils.py index c6b9fb4..09db8da 100644 --- a/src/sincere_singularities/utils.py +++ b/src/sincere_singularities/utils.py @@ -4,9 +4,15 @@ from typing import TypeAlias, TypeVar, get_args, get_origin import dacite +import disnake CURRENT_DIR = Path(__file__).parent.absolute() +DISNAKE_COLORS = { + ":pizza:": disnake.Color.from_rgb(229, 97, 38), + ":sushi:": disnake.Color.from_rgb(255, 153, 153), +} + @dataclass(unsafe_hash=True) class RestaurantJson: From 5aa704b91d50721064380ec1d31b6a3e0bf0513b Mon Sep 17 00:00:00 2001 From: koviubi56 Date: Wed, 24 Jul 2024 12:01:54 +0200 Subject: [PATCH 09/43] Add `check_similarity` (#7) * Add `check_similarity` This function returns the similarity of two strings. Signed-off-by: koviubi56 * Add `compare_sentences` Measure of the strings' similarity as a float using Sentence Transformer's MiniLM. Signed-off-by: koviubi56 --------- Signed-off-by: koviubi56 --- requirements.txt | 2 ++ src/sincere_singularities/utils.py | 41 +++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index dd54fb4..6186dd1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,5 @@ disnake~=2.9.2 python-dotenv~=1.0.1 dacite~=1.8.1 +torch~=2.3.1 +sentence-transformers~=3.0.1 diff --git a/src/sincere_singularities/utils.py b/src/sincere_singularities/utils.py index 09db8da..563551a 100644 --- a/src/sincere_singularities/utils.py +++ b/src/sincere_singularities/utils.py @@ -1,3 +1,4 @@ +import difflib import json from dataclasses import dataclass from pathlib import Path @@ -5,14 +6,20 @@ import dacite import disnake +import torch +from sentence_transformers import SentenceTransformer, util CURRENT_DIR = Path(__file__).parent.absolute() - DISNAKE_COLORS = { ":pizza:": disnake.Color.from_rgb(229, 97, 38), ":sushi:": disnake.Color.from_rgb(255, 153, 153), } +# Use GPU if available +device = "cuda" if torch.cuda.is_available() else "cpu" +# Load the MiniLM SentenceTransformer Model +minilm_model = SentenceTransformer("all-MiniLM-L6-v2", device=device) + @dataclass(unsafe_hash=True) class RestaurantJson: @@ -57,3 +64,35 @@ def load_json(filename: str, json_type: type[T]) -> T: typed_json: T = dacite.from_dict(json_type, loaded_json) return typed_json + + +def check_pattern_similarity(first: str, second: str) -> float: + """ + Measure of the strings' similarity as a float using Gestalt Pattern Matching Algorithm. + + Args: + first (str): The first string. + second (str): The second string. + + Returns: + float: The similarity of the two strings [0, 1] + """ + return difflib.SequenceMatcher(None, first, second).ratio() + + +def compare_sentences(first: str, second: str) -> float: + """ + Measure of the strings' similarity as a float using Sentence Transformer's MiniLM. + + Args: + first (str): The first string. + second (str): The second string. + + Returns: + float: The similarity of the two strings [0, 1] + """ + # Encode sentences in batch to speed up the process + embeddings = minilm_model.encode([first, second], convert_to_tensor=True, device=device) + # Check Similarity using Cosine Similarity + similarity = util.pytorch_cos_sim(embeddings[0], embeddings[1]) + return similarity.item() # type: ignore[no-any-return] From 146d931601e192684fca3aff25bf0bea49ccf8b0 Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Wed, 24 Jul 2024 14:09:03 +0200 Subject: [PATCH 10/43] Implement Content Checks. (#9) * Implement Content Checks. - Implemented `check_pattern_similarity` and `compare_sentences` in `Restaurant.check_order` * Add Numpy Versioning Requirement * Fix Formatting --- requirements.txt | 1 + src/sincere_singularities/modules/restaurant.py | 11 +++++------ .../modules/sentiment_analysis.py | 13 ------------- 3 files changed, 6 insertions(+), 19 deletions(-) delete mode 100644 src/sincere_singularities/modules/sentiment_analysis.py diff --git a/requirements.txt b/requirements.txt index 6186dd1..f0606a9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ python-dotenv~=1.0.1 dacite~=1.8.1 torch~=2.3.1 sentence-transformers~=3.0.1 +numpy<2 diff --git a/src/sincere_singularities/modules/restaurant.py b/src/sincere_singularities/modules/restaurant.py index 15c98f6..be28845 100644 --- a/src/sincere_singularities/modules/restaurant.py +++ b/src/sincere_singularities/modules/restaurant.py @@ -4,8 +4,7 @@ from disnake import MessageInteraction from sincere_singularities.modules.order import Order, OrderView -from sincere_singularities.modules.sentiment_analysis import check_content -from sincere_singularities.utils import RestaurantJson +from sincere_singularities.utils import RestaurantJson, check_pattern_similarity, compare_sentences if TYPE_CHECKING: from sincere_singularities.modules.restaurants_view import Restaurants @@ -78,21 +77,21 @@ def check_order(self, correct_order: Order) -> float: assert customer_information # TODO: blame the user for not filling out the Customer Information # Customer Name - name_check = check_content(correct_customer_information.address, customer_information.address) + name_check = check_pattern_similarity(correct_customer_information.address, customer_information.address) score -= score_percentile + (-score_percentile * name_check) # Customer Address - address_check = check_content(correct_customer_information.address, customer_information.address) + address_check = check_pattern_similarity(correct_customer_information.address, customer_information.address) score -= score_percentile + (-score_percentile * address_check) # Delivery Time - delivery_time_check = check_content( + delivery_time_check = compare_sentences( correct_customer_information.delivery_time, customer_information.delivery_time ) score -= score_percentile + (-score_percentile * delivery_time_check) # Extra Information - extra_info_check = check_content( + extra_info_check = compare_sentences( correct_customer_information.extra_information, customer_information.extra_information ) score -= score_percentile + (-score_percentile * extra_info_check) diff --git a/src/sincere_singularities/modules/sentiment_analysis.py b/src/sincere_singularities/modules/sentiment_analysis.py deleted file mode 100644 index 2eae825..0000000 --- a/src/sincere_singularities/modules/sentiment_analysis.py +++ /dev/null @@ -1,13 +0,0 @@ -def check_content(obj1: str, obj2: str) -> float: - """ - Checking if the content of two objects is roughly same using sentiment analysis. - - Args: - obj1: First Object to Check. - obj2: Second Object to Check. - - Returns: Similarity of two objects in percentage (as a float). - - """ - # TODO: Implement Sentiment Analysis - return float(obj1 == obj2) From 369c8cb2991ee49e7644d3ca17e15d8129154be4 Mon Sep 17 00:00:00 2001 From: koviubi56 Date: Wed, 24 Jul 2024 15:51:28 +0200 Subject: [PATCH 11/43] Add generate_random_avatar_url (#10) Generates a random "notionists" (CC0) Dicebear avatar image URL. Signed-off-by: koviubi56 --- src/sincere_singularities/utils.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/sincere_singularities/utils.py b/src/sincere_singularities/utils.py index 563551a..74f8ded 100644 --- a/src/sincere_singularities/utils.py +++ b/src/sincere_singularities/utils.py @@ -1,5 +1,6 @@ import difflib import json +import random from dataclasses import dataclass from pathlib import Path from typing import TypeAlias, TypeVar, get_args, get_origin @@ -96,3 +97,16 @@ def compare_sentences(first: str, second: str) -> float: # Check Similarity using Cosine Similarity similarity = util.pytorch_cos_sim(embeddings[0], embeddings[1]) return similarity.item() # type: ignore[no-any-return] + + +def generate_random_avatar_url() -> str: + """ + Generate a random avatar image URL. + + Returns: + str: A random image URL. + """ + seed = random.random() + flip = random.choice(("true", "false")) + background_color = hex(random.randrange(0x000000, 0xFFFFFF))[2:].zfill(6) + return f"https://api.dicebear.com/9.x/notionists/svg?seed={seed}&flip={flip}&backgroundColor={background_color}" From fbf0341b1d5e99944d96d9e82fe58a1a5d020fbb Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Wed, 24 Jul 2024 20:07:39 +0200 Subject: [PATCH 12/43] Order Queue System (#11) --- src/sincere_singularities/bot.py | 43 +++++++-- src/sincere_singularities/modules/order.py | 34 +++++-- .../modules/order_queue.py | 89 +++++++++++++++++++ .../modules/restaurant.py | 21 +++-- .../modules/restaurants_view.py | 5 +- 5 files changed, 173 insertions(+), 19 deletions(-) create mode 100644 src/sincere_singularities/modules/order_queue.py diff --git a/src/sincere_singularities/bot.py b/src/sincere_singularities/bot.py index 932e381..a68678f 100644 --- a/src/sincere_singularities/bot.py +++ b/src/sincere_singularities/bot.py @@ -1,18 +1,51 @@ -import disnake +from disnake import ApplicationCommandInteraction, Intents, TextChannel from disnake.ext import commands +from sincere_singularities.modules.order_queue import OrderQueue from sincere_singularities.modules.restaurants_view import Restaurants -intents = disnake.Intents.default() +intents = Intents.default() bot = commands.InteractionBot(intents=intents) @bot.slash_command(name="start_game") -async def start_game(inter: disnake.ApplicationCommandInteraction) -> None: +async def start_game(inter: ApplicationCommandInteraction) -> None: """Main Command of our Game: /start_game""" + # Check if the Message was sent in a Text Channel + if not isinstance(inter.channel, TextChannel): + await inter.response.send_message( + "You can only start a Game Session inside of a Text Channel!", ephemeral=True + ) + + # Start Order Queue + order_queue = OrderQueue(inter) # Load Restaurants - restaurants = Restaurants(inter) + restaurants = Restaurants(inter, order_queue) await inter.response.send_message(embed=restaurants.embeds[0], view=restaurants.view, ephemeral=True) + await order_queue.start_orders() + + # Creating Temporary example order + from sincere_singularities.modules.order import CustomerInfo, Order + + customer_info = CustomerInfo( + order_id="Test123", + name="Customer Name", + address="Customer Address", + delivery_time="9 o'clock.", + extra_information="Dont ring the bell.", + ) + example_order_text = str( + "OrderID: Test123 \n" + "Hello, my name is Customer Name. I would like to have 2 Pizza Starter0 and a " + "Main Course0 delivered to my house Customer Address at 9 o'clock. " + "Please dont ring the bell." + ) + example_order = Order(customer_information=customer_info, restaurant_name="Pizzaria") + example_order.foods["Starters"].append("Pizza Starter0") + example_order.foods["Starters"].append("Pizza Starter0") + example_order.foods["Main Courses"].append("Main Course0") + + await order_queue.create_order("Customer Name", example_order_text, example_order) @bot.event @@ -20,5 +53,5 @@ async def on_ready() -> None: """Bot information logging when starting up.""" print( f"Logged in as {bot.user} (ID: {bot.user.id}).\n" - f"Running on {len(bot.guilds)} servers with {bot.latency*1000:,.2f} ms latency.", + f"Running on {len(bot.guilds)} servers with {bot.latency * 1000:,.2f} ms latency.", ) diff --git a/src/sincere_singularities/modules/order.py b/src/sincere_singularities/modules/order.py index 7310ba1..e5d419d 100644 --- a/src/sincere_singularities/modules/order.py +++ b/src/sincere_singularities/modules/order.py @@ -16,6 +16,7 @@ class CustomerInfo: """The Dataclass Containing Information added in the Customer Information Section.""" + order_id: str name: str address: str delivery_time: str @@ -27,16 +28,17 @@ class Order: """The Dataclass Containing Order Information.""" customer_information: CustomerInfo | None = None + restaurant_name: str | None = None foods: defaultdict[str, list[str]] = field(default_factory=lambda: defaultdict(list[str])) class CustomerInfoModal(disnake.ui.Modal): """The Modal for entering Customer Information.""" - def __init__(self, order_view: "OrderView", order: Order) -> None: + def __init__(self, order_view: "OrderView") -> None: self.order_view = order_view - self.order = order components = [ + disnake.ui.TextInput(label="Order ID", custom_id="order_id", style=TextInputStyle.short, max_length=64), disnake.ui.TextInput(label="Name", custom_id="name", style=TextInputStyle.short, max_length=64), disnake.ui.TextInput(label="Address", custom_id="address", style=TextInputStyle.short, max_length=64), disnake.ui.TextInput( @@ -54,7 +56,15 @@ def __init__(self, order_view: "OrderView", order: Order) -> None: async def callback(self, inter: ModalInteraction) -> None: """The Callback when the User has entered the Customer Information.""" - self.order.customer_information = CustomerInfo( + if not self.order_view.restaurant.order_queue.get_order_by_id(inter.text_values["order_id"]): + embed = self.order_view.embed + embed.add_field(name="Error", value="Incorrect order ID. Try again.", inline=False) + await inter.response.edit_message(view=self.order_view, embed=embed) + return + + self.order_view.order.restaurant_name = self.order_view.restaurant.name + self.order_view.order.customer_information = CustomerInfo( + order_id=inter.text_values["order_id"], name=inter.text_values["name"], address=inter.text_values["address"], delivery_time=inter.text_values["time"], @@ -129,7 +139,7 @@ class OrderView(disnake.ui.View): def __init__(self, restaurant: "Restaurant") -> None: super().__init__() self.restaurant = restaurant - self.order = self.restaurant.order + self.order = Order() for i, menu_item in enumerate(restaurant.menu): self.add_item(MenuItemButton(restaurant, self, self.order, menu_item, i)) @@ -155,13 +165,25 @@ def embed(self) -> disnake.Embed: @disnake.ui.button(label="Customer Information", style=disnake.ButtonStyle.success, row=2) async def _customer_information(self, _: disnake.ui.Button, inter: disnake.MessageInteraction) -> None: - await inter.response.send_modal(CustomerInfoModal(self, self.order)) + await inter.response.send_modal(CustomerInfoModal(self)) @disnake.ui.button(label="Done", style=disnake.ButtonStyle.success, row=2) async def _order_done(self, _: disnake.ui.Button, inter: disnake.MessageInteraction) -> None: # Sending Order Placed Message and back to Start Screen + if not self.order.customer_information: + await inter.response.edit_message( + "Customer information missing!", + embed=self.restaurant.restaurants.embeds[0], + view=self.restaurant.restaurants.view, + ) + return + user_order = self.restaurant.order_queue.get_order_by_id(self.order.customer_information.order_id) + assert user_order + correctness = self.restaurant.check_order(self.order, user_order) + await self.restaurant.order_queue.discard_order(self.order.customer_information.order_id) + await inter.response.edit_message( - "Order placed successfully!", + f"Order placed successfully! Correctness: {round(correctness, 4)*100}%", embed=self.restaurant.restaurants.embeds[0], view=self.restaurant.restaurants.view, ) diff --git a/src/sincere_singularities/modules/order_queue.py b/src/sincere_singularities/modules/order_queue.py new file mode 100644 index 0000000..f82a3a4 --- /dev/null +++ b/src/sincere_singularities/modules/order_queue.py @@ -0,0 +1,89 @@ +from contextlib import suppress + +from disnake import ApplicationCommandInteraction, ChannelType, Thread, Webhook, WebhookMessage +from disnake.ext.commands.errors import CommandInvokeError + +from sincere_singularities.modules.order import Order +from sincere_singularities.utils import generate_random_avatar_url + + +class OrderQueue: + """The Class for managing the order queue. Orders can be spawned and deleted from here.""" + + webhook: Webhook + orders_thread: Thread + + def __init__(self, inter: ApplicationCommandInteraction) -> None: + self.user = inter.user + self.channel = inter.channel + self.orders: dict[str, tuple[Order, WebhookMessage]] = {} + + async def start_orders(self) -> None: + """Start the orders queue. Spawn a new Webhook and Order Thread""" + # Creating the Order Webhook + try: + self.webhook = await self.channel.create_webhook(name="GAME NAME Order Webhook") + except CommandInvokeError: + await self.channel.send( + "Can't start GAME NAME: Maximum Amount of Webhooks reached. Delete Webhooks or try in another channel!" + ) + await self.stop_orders() + + # Creating the Orders Thread + self.orders_thread = await self.channel.create_thread( + name="Orders Thread", type=ChannelType.public_thread, invitable=False + ) + await self.orders_thread.add_user(self.user) + + async def create_order(self, customer_name: str, order_message: str, order_result: Order) -> None: + """ + Create a new Order, sends a message to the Discord and stores the result to check. + + Args: + customer_name: The full name of the customer. + order_message: The message to send to the Discord channel. + order_result: The correct result the Order should give + """ + order_message = await self.webhook.send( + content=order_message, + username=customer_name, + avatar_url=generate_random_avatar_url(), + wait=True, + thread=self.orders_thread, + ) + + assert order_result.customer_information + self.orders[order_result.customer_information.order_id] = (order_result, order_message) + + def get_order_by_id(self, order_id: str) -> Order | None: + """ + Get a specific Order by ID. + + Args: + order_id: The ID of the Order to retrieve. + + Returns: + The Correct Order, the WebhookMessage (to delete after the order is done) + """ + if order := self.orders.get(order_id): + return order[0] + return None + + async def discard_order(self, order_id: str) -> None: + """ + Discard a specific Order by ID after its completed. + + Args: + order_id: ID of the Order to discard. + """ + with suppress(KeyError): + del self.orders[order_id] + + async def stop_orders(self) -> None: + """Stop All Orders (when stopping the game).""" + # TODO: Make sure these cant fail (or catch specific Errors) + with suppress(Exception): + # Deleting Webhook + await self.webhook.delete() + # Deleting Orders Thread + await self.orders_thread.delete() diff --git a/src/sincere_singularities/modules/restaurant.py b/src/sincere_singularities/modules/restaurant.py index be28845..d58fad8 100644 --- a/src/sincere_singularities/modules/restaurant.py +++ b/src/sincere_singularities/modules/restaurant.py @@ -7,6 +7,7 @@ from sincere_singularities.utils import RestaurantJson, check_pattern_similarity, compare_sentences if TYPE_CHECKING: + from sincere_singularities.modules.order_queue import OrderQueue from sincere_singularities.modules.restaurants_view import Restaurants @@ -42,7 +43,7 @@ def __init__(self, restaurants: "Restaurants", restaurant_json: RestaurantJson) self.menu = restaurant_json.menu # Order Related - self.order = Order() + self.order_queue: OrderQueue = restaurants.order_queue async def enter_menu(self, inter: MessageInteraction) -> None: """ @@ -54,27 +55,33 @@ async def enter_menu(self, inter: MessageInteraction) -> None: view = OrderView(self) await inter.response.edit_message(embed=view.embed, view=view) - def check_order(self, correct_order: Order) -> float: + def check_order(self, order: Order, correct_order: Order) -> float: """ Checking if the order was correctly placed by the user. Args: + order: The Order to check. correct_order: The Correct Order to check against. Returns: How correct the order was placed in percentage (as a float) """ score = 1.0 - # The effect on the Score each wrong answer should have (Length of Menu Items + Customer Information Items) - score_percentile = 1 / (len(correct_order.foods) + 4) + # The effect on the Score each wrong answer should have + # (Length of Menu Items + Customer Information Items + 1 for the restaurant) + score_percentile = 1 / (len(correct_order.foods) + 4 + 1) # Subtracting Sentiment Analysis Scores of the Customer Information # This is achieved using a linear interpolation, meaning if the check gives 1.0, 0.0 will be subtracted from # the score, but when the check gives 0.0, score_percentile will be subtracted correct_customer_information = correct_order.customer_information assert correct_customer_information - customer_information = self.order.customer_information - assert customer_information # TODO: blame the user for not filling out the Customer Information + customer_information = order.customer_information + assert customer_information + + # Restaurant + if correct_order.restaurant_name != order.restaurant_name: + score -= score_percentile # Customer Name name_check = check_pattern_similarity(correct_customer_information.address, customer_information.address) @@ -99,7 +106,7 @@ def check_order(self, correct_order: Order) -> float: # Now we can subtract score points for each wrong order # Getting every order item correct_order_items = [item for menu_items in correct_order.foods.values() for item in menu_items] - all_order_items = [item for menu_items in self.order.foods.values() for item in menu_items] + all_order_items = [item for menu_items in order.foods.values() for item in menu_items] # Finding Differences between Orders and subtracting from Score order_differences = count_differences(correct_order_items, all_order_items) score -= score_percentile * order_differences diff --git a/src/sincere_singularities/modules/restaurants_view.py b/src/sincere_singularities/modules/restaurants_view.py index 306619c..6044504 100644 --- a/src/sincere_singularities/modules/restaurants_view.py +++ b/src/sincere_singularities/modules/restaurants_view.py @@ -2,6 +2,7 @@ import disnake +from sincere_singularities.modules.order_queue import OrderQueue from sincere_singularities.modules.restaurant import Restaurant from sincere_singularities.utils import DISNAKE_COLORS, RestaurantJsonType, load_json @@ -58,13 +59,15 @@ async def _stop_game(self, *_: disnake.ui.Button | disnake.MessageInteraction) - # TODO: Savestates? # TODO: fix awful typing when implemented await self.ctx.inter.delete_original_message() + await self.ctx.order_queue.stop_orders() class Restaurants: """Class to Manage the Restaurants & UI""" - def __init__(self, inter: disnake.ApplicationCommandInteraction) -> None: + def __init__(self, inter: disnake.ApplicationCommandInteraction, order_queue: OrderQueue) -> None: self.inter = inter + self.order_queue = order_queue # Loading Restaurants self.restaurants_json = load_json("restaurants.json", RestaurantJsonType) From 9f42715a04d92a79e599293721f6d80234088944 Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Thu, 25 Jul 2024 11:32:07 +0200 Subject: [PATCH 13/43] Game Quality of Life Improvements (#12) - Added clear_webhooks and clear_threads commands - Improved Error/Success Messages --- src/sincere_singularities/bot.py | 42 ++++++++++++++++++++++ src/sincere_singularities/modules/order.py | 36 ++++++++++++++----- 2 files changed, 69 insertions(+), 9 deletions(-) diff --git a/src/sincere_singularities/bot.py b/src/sincere_singularities/bot.py index a68678f..fdd1039 100644 --- a/src/sincere_singularities/bot.py +++ b/src/sincere_singularities/bot.py @@ -8,6 +8,47 @@ bot = commands.InteractionBot(intents=intents) +@bot.slash_command(name="clear_webhooks") +async def clear_webhooks(inter: ApplicationCommandInteraction) -> None: + """Clears the webhoooks in a channel.""" + # Check user permissions + permissions = inter.channel.permissions_for(inter.author) + if not permissions.manage_webhooks: + await inter.response.send_message("You dont have the permissions to Manage Webhooks!", ephemeral=True) + return + + # Check if the Message was sent in a Text Channel + if not isinstance(inter.channel, TextChannel): + await inter.response.send_message("Im only able to clear webhooks inside of a Text Channel!", ephemeral=True) + return + + webhooks = await inter.channel.webhooks() + for webhook in webhooks: + await webhook.delete() + + await inter.response.send_message("Webhooks cleared!", ephemeral=True) + + +@bot.slash_command(name="clear_threads") +async def clear_threads(inter: ApplicationCommandInteraction) -> None: + """Clears the threads in a channel.""" + # Check user permissions + permissions = inter.channel.permissions_for(inter.author) + if not permissions.manage_threads: + await inter.response.send_message("You dont have the permissions to Manage Threads!", ephemeral=True) + return + + # Check if the Message was sent in a Text Channel + if not isinstance(inter.channel, TextChannel): + await inter.response.send_message("Im only able to clear threads inside of a Text Channel!", ephemeral=True) + return + + for thread in inter.channel.threads: + await thread.delete() + + await inter.response.send_message("Threads cleared!", ephemeral=True) + + @bot.slash_command(name="start_game") async def start_game(inter: ApplicationCommandInteraction) -> None: """Main Command of our Game: /start_game""" @@ -16,6 +57,7 @@ async def start_game(inter: ApplicationCommandInteraction) -> None: await inter.response.send_message( "You can only start a Game Session inside of a Text Channel!", ephemeral=True ) + return # Start Order Queue order_queue = OrderQueue(inter) diff --git a/src/sincere_singularities/modules/order.py b/src/sincere_singularities/modules/order.py index e5d419d..d03cd12 100644 --- a/src/sincere_singularities/modules/order.py +++ b/src/sincere_singularities/modules/order.py @@ -56,9 +56,17 @@ def __init__(self, order_view: "OrderView") -> None: async def callback(self, inter: ModalInteraction) -> None: """The Callback when the User has entered the Customer Information.""" + # Check if wrong OrderID was entered if not self.order_view.restaurant.order_queue.get_order_by_id(inter.text_values["order_id"]): + # Adding Error Message embed = self.order_view.embed - embed.add_field(name="Error", value="Incorrect order ID. Try again.", inline=False) + embed.insert_field_at(index=0, name=" ", value=" ", inline=False) + embed.insert_field_at( + index=1, + name=":rotating_light: :warning: Error :warning: :rotating_light:", + value="**Incorrect order ID. Try again.**", + inline=False, + ) await inter.response.edit_message(view=self.order_view, embed=embed) return @@ -171,19 +179,29 @@ async def _customer_information(self, _: disnake.ui.Button, inter: disnake.Messa async def _order_done(self, _: disnake.ui.Button, inter: disnake.MessageInteraction) -> None: # Sending Order Placed Message and back to Start Screen if not self.order.customer_information: - await inter.response.edit_message( - "Customer information missing!", - embed=self.restaurant.restaurants.embeds[0], - view=self.restaurant.restaurants.view, + embed = self.embed + embed.insert_field_at(index=0, name=" ", value=" ", inline=False) + embed.insert_field_at( + index=1, + name=":rotating_light: :warning: Error :warning: :rotating_light:", + value="**Customer information missing!**", + inline=False, ) + await inter.response.edit_message(embed=embed, view=self) return + user_order = self.restaurant.order_queue.get_order_by_id(self.order.customer_information.order_id) assert user_order correctness = self.restaurant.check_order(self.order, user_order) await self.restaurant.order_queue.discard_order(self.order.customer_information.order_id) - await inter.response.edit_message( - f"Order placed successfully! Correctness: {round(correctness, 4)*100}%", - embed=self.restaurant.restaurants.embeds[0], - view=self.restaurant.restaurants.view, + # Adding Info to embed + embed = self.restaurant.restaurants.embeds[0] + embed.insert_field_at(index=0, name=" ", value=" ", inline=False) + embed.insert_field_at( + index=1, + name=":loudspeaker: :white_check_mark: Info :white_check_mark: :loudspeaker:", + value=f"**Order placed successfully! Correctness: {round(correctness, 4)*100}%**", + inline=False, ) + await inter.response.edit_message(embed=embed, view=self.restaurant.restaurants.view) From a08ecb23bf7cc9a44a2681389fc3f5eb486b00f4 Mon Sep 17 00:00:00 2001 From: koviubi56 Date: Thu, 25 Jul 2024 15:55:26 +0200 Subject: [PATCH 14/43] Add points and buying restaurants (#13) * Add points and buying restaurants First restaurant is unlocked, every other one requires some points. After completing an order, the user gets `round(correctness * 10)` points. Signed-off-by: koviubi56 * Add docstrings Signed-off-by: koviubi56 --------- Signed-off-by: koviubi56 --- src/sincere_singularities/bot.py | 1 + .../data/restaurants.json | 42 ++++-- src/sincere_singularities/modules/order.py | 6 +- src/sincere_singularities/modules/points.py | 136 ++++++++++++++++++ .../modules/restaurant.py | 1 + .../modules/restaurants_view.py | 45 +++++- src/sincere_singularities/utils.py | 1 + 7 files changed, 218 insertions(+), 14 deletions(-) create mode 100644 src/sincere_singularities/modules/points.py diff --git a/src/sincere_singularities/bot.py b/src/sincere_singularities/bot.py index fdd1039..4810b48 100644 --- a/src/sincere_singularities/bot.py +++ b/src/sincere_singularities/bot.py @@ -52,6 +52,7 @@ async def clear_threads(inter: ApplicationCommandInteraction) -> None: @bot.slash_command(name="start_game") async def start_game(inter: ApplicationCommandInteraction) -> None: """Main Command of our Game: /start_game""" + # TODO: in the entire game, make sure that buttons can only be clicked on by the user who it was intended for! # Check if the Message was sent in a Text Channel if not isinstance(inter.channel, TextChannel): await inter.response.send_message( diff --git a/src/sincere_singularities/data/restaurants.json b/src/sincere_singularities/data/restaurants.json index 017867e..c59e052 100644 --- a/src/sincere_singularities/data/restaurants.json +++ b/src/sincere_singularities/data/restaurants.json @@ -3,22 +3,48 @@ "name": "Pizzaria", "icon": ":pizza:", "description": "Pizzaria Placeholder Description", + "points": 0, "menu": { - "Starters": ["Pizza Starter0", "Pizza Starter1"], - "Main Courses": ["Main Course0", "Pizza Main Course1"], - "Desserts": ["Pizza Dessert0", "Pizza Dessert1"], - "Drinks": ["Pizza Drink0", "Pizza Drink1"] + "Starters": [ + "Pizza Starter0", + "Pizza Starter1" + ], + "Main Courses": [ + "Main Course0", + "Pizza Main Course1" + ], + "Desserts": [ + "Pizza Dessert0", + "Pizza Dessert1" + ], + "Drinks": [ + "Pizza Drink0", + "Pizza Drink1" + ] } }, { "name": "Sushi Restaurant", "icon": ":sushi:", "description": "Sushi Placeholder Description", + "points": 300, "menu": { - "Starters": ["Sushi Starter0", "Sushi Starter1"], - "Main Courses": ["Sushi Main Course0", "Sushi Main Course1"], - "Desserts": ["Sushi Dessert0", "Sushi Dessert1"], - "Drinks": ["Drink0", "Drink1"] + "Starters": [ + "Sushi Starter0", + "Sushi Starter1" + ], + "Main Courses": [ + "Sushi Main Course0", + "Sushi Main Course1" + ], + "Desserts": [ + "Sushi Dessert0", + "Sushi Dessert1" + ], + "Drinks": [ + "Drink0", + "Drink1" + ] } } ] diff --git a/src/sincere_singularities/modules/order.py b/src/sincere_singularities/modules/order.py index d03cd12..cd061f4 100644 --- a/src/sincere_singularities/modules/order.py +++ b/src/sincere_singularities/modules/order.py @@ -6,6 +6,7 @@ import disnake from disnake import ButtonStyle, MessageInteraction, ModalInteraction, TextInputStyle +from sincere_singularities.modules.points import add_points, get_points from sincere_singularities.utils import DISNAKE_COLORS if TYPE_CHECKING: @@ -194,6 +195,8 @@ async def _order_done(self, _: disnake.ui.Button, inter: disnake.MessageInteract assert user_order correctness = self.restaurant.check_order(self.order, user_order) await self.restaurant.order_queue.discard_order(self.order.customer_information.order_id) + points = round(correctness * 10) # 100% -> 10p + add_points(inter.user.id, points) # Adding Info to embed embed = self.restaurant.restaurants.embeds[0] @@ -201,7 +204,8 @@ async def _order_done(self, _: disnake.ui.Button, inter: disnake.MessageInteract embed.insert_field_at( index=1, name=":loudspeaker: :white_check_mark: Info :white_check_mark: :loudspeaker:", - value=f"**Order placed successfully! Correctness: {round(correctness, 4)*100}%**", + value=f"**Order placed successfully! Correctness: {round(correctness, 4)*100}% You gained {points} points;" + f" you now have {get_points(inter.user.id)}!**", inline=False, ) await inter.response.edit_message(embed=embed, view=self.restaurant.restaurants.view) diff --git a/src/sincere_singularities/modules/points.py b/src/sincere_singularities/modules/points.py new file mode 100644 index 0000000..8130abf --- /dev/null +++ b/src/sincere_singularities/modules/points.py @@ -0,0 +1,136 @@ +from collections import defaultdict +from typing import TypedDict + +from sincere_singularities.utils import RestaurantJson, RestaurantJsonType, load_json + + +def get_restaurant_by_name(name: str) -> RestaurantJson: + """ + Get a restaurant by its name. + + Args: + name (str): The name of the restaurant as it appears in `restaurants.json`. + + Raises: + ValueError: Raised when a restaurant with that name wasn't found. + + Returns: + RestaurantJson: The restaurant. + """ + for restaurant in load_json("restaurants.json", RestaurantJsonType): + if restaurant.name == name: + return restaurant + raise ValueError(f"Restaurant named {name!r} doesn't exist") + + +# vvv temporary until #6 gets merges vvv + + +class TemporaryDatabaseEntry(TypedDict): + """ + An entry to the temporary database. + + Args: + points (int): How many points the user has. + restaurants (list[str]): The names of the restaurants that the user owns. + """ + + points: int + restaurants: list[str] + + +temporary_database: defaultdict[int, TemporaryDatabaseEntry] = defaultdict( + lambda: TemporaryDatabaseEntry( + {"points": 0, "restaurants": [load_json("restaurants.json", RestaurantJsonType)[0].name]} + ) +) + + +def get_points(user_id: int) -> int: + """ + Get the points that the user has. + + Args: + user_id (int): The user's ID. + + Returns: + int: The amount of points that the user has. + """ + return temporary_database[user_id]["points"] + + +def add_points(user_id: int, points: int) -> None: + """ + Add points to the user. + + Args: + user_id (int): The user's ID. + points (int): The amount of points to add. + """ + temporary_database[user_id]["points"] += points + + +def get_restaurants(user_id: int) -> list[str]: + """ + Get the restaurants' name that the user owns. + + Args: + user_id (int): The user's ID. + + Returns: + list[str]: The names of the restaurants that the user owns. + """ + return temporary_database[user_id]["restaurants"] + + +def has_restaurant(user_id: int, restaurant_name: str) -> bool: + """ + Returns whether the user owns a restaurant. + + Args: + user_id (int): The user's ID + restaurant_name (str): The restaurant's name. + + Returns: + bool: Whether the user owns that restaurant. + """ + return restaurant_name in get_restaurants(user_id) + + +def add_restaurant(user_id: int, restaurant: str) -> None: + """ + Add a restaurant to the user. + + Args: + user_id (int): The user's ID. + restaurant (str): The restaurant's name. + """ + temporary_database[user_id]["restaurants"].append(restaurant) + + +# ^^^ temporary ^^^ + + +def buy_restaurant(user_id: int, restaurant_name: str) -> None: + """ + Buy a restaurant. + + This function deducts the points and adds the restaurant to the user. + + Args: + user_id (int): The user's ID. + restaurant_name (str): The restaurant's name. + + Raises: + ValueError: Raised when the user already owns the restaurant. + ValueError: Raised when the user doesn't have the points necessary to buy the restaurant. + """ + if has_restaurant(user_id, restaurant_name): + # should be disallowed + raise ValueError(f"User {user_id} already has restaurant {restaurant_name}!") + restaurant = get_restaurant_by_name(restaurant_name) + if get_points(user_id) - restaurant.points < 0: + # should be disallowed + raise ValueError(f"User {user_id} doesn't have the necessary points to buy {restaurant_name}!") + add_points(user_id, -restaurant.points) + add_restaurant(user_id, restaurant_name) diff --git a/src/sincere_singularities/modules/restaurant.py b/src/sincere_singularities/modules/restaurant.py index d58fad8..f1ca81a 100644 --- a/src/sincere_singularities/modules/restaurant.py +++ b/src/sincere_singularities/modules/restaurant.py @@ -40,6 +40,7 @@ def __init__(self, restaurants: "Restaurants", restaurant_json: RestaurantJson) self.name = restaurant_json.name self.icon = restaurant_json.icon self.description = restaurant_json.description + self.points = restaurant_json.points self.menu = restaurant_json.menu # Order Related diff --git a/src/sincere_singularities/modules/restaurants_view.py b/src/sincere_singularities/modules/restaurants_view.py index 6044504..203bade 100644 --- a/src/sincere_singularities/modules/restaurants_view.py +++ b/src/sincere_singularities/modules/restaurants_view.py @@ -3,18 +3,40 @@ import disnake from sincere_singularities.modules.order_queue import OrderQueue +from sincere_singularities.modules.points import buy_restaurant, get_points, has_restaurant from sincere_singularities.modules.restaurant import Restaurant from sincere_singularities.utils import DISNAKE_COLORS, RestaurantJsonType, load_json +class RestaurantPurchaseView(disnake.ui.View): + """View subclass for buying a restaurant""" + + def __init__(self, user_id: int, restaurant: Restaurant, parent: "RestaurantsView") -> None: + super().__init__(timeout=None) + self.user_id = user_id + self.restaurant = restaurant + self.parent = parent + if get_points(user_id) < restaurant.points: + self._buy.disabled = True + + @disnake.ui.button(label="Buy", style=disnake.ButtonStyle.success) + async def _buy(self, _: disnake.ui.Button, inter: disnake.MessageInteraction) -> None: + buy_restaurant(self.user_id, self.restaurant.name) + await inter.response.edit_message(view=self.parent, embed=self.parent.embeds[self.parent.index]) + + @disnake.ui.button(label="Cancel", style=disnake.ButtonStyle.secondary) + async def _cancel(self, _: disnake.ui.Button, inter: disnake.MessageInteraction) -> None: + await inter.response.edit_message(view=self.parent, embed=self.parent.embeds[self.parent.index]) + + class RestaurantsView(disnake.ui.View): """View Subclass for Choosing the Restaurant""" - def __init__(self, ctx: "Restaurants", embeds: list[disnake.Embed]) -> None: + def __init__(self, ctx: "Restaurants", embeds: list[disnake.Embed], index: int = 0) -> None: super().__init__(timeout=None) self.ctx = ctx self.embeds = embeds - self.index = 0 + self.index = index # Sets the footer of the embeds with their respective page numbers. for i, embed in enumerate(self.embeds): @@ -35,10 +57,22 @@ async def _prev_page(self, _: disnake.ui.Button, inter: disnake.MessageInteracti @disnake.ui.button(label="Enter Restaurant", style=disnake.ButtonStyle.success, row=0) async def _enter_restaurant(self, _: disnake.ui.Button, inter: disnake.MessageInteraction) -> None: + # Find Restaurant based on current index + restaurant = self.ctx.restaurants[self.index] + if not has_restaurant(inter.user.id, restaurant.name): + user_points = get_points(inter.user.id) + await inter.response.edit_message( + view=RestaurantPurchaseView(inter.user.id, restaurant, self), + embed=disnake.Embed( + title="You do not own this restaurant.", + description=f"It costs {restaurant.points} points.\nYou have {user_points}.\nAfter buying it," + f" you'd have {user_points - restaurant.points}.", + colour=disnake.Color.yellow(), + ), + ) + return # Stopping view self.stop() - # Enter Restaurant based on current index - restaurant = self.ctx.restaurants[self.index] await restaurant.enter_menu(inter) @disnake.ui.button(emoji="▶", style=disnake.ButtonStyle.secondary, row=0) @@ -96,7 +130,8 @@ def embeds(self) -> list[disnake.Embed]: for restaurant in self.restaurants_json: embed = disnake.Embed( title=f"{restaurant.icon} {restaurant.name} {restaurant.icon}", - description=f"{restaurant.description} \n", + description=f"{restaurant.description} \n**Required points**: {restaurant.points}" + f" (you have {get_points(self.inter.user.id)})", colour=DISNAKE_COLORS.get(restaurant.icon, disnake.Color.random()), ) # Adding an Empty Field for better formatting diff --git a/src/sincere_singularities/utils.py b/src/sincere_singularities/utils.py index 74f8ded..7051ba1 100644 --- a/src/sincere_singularities/utils.py +++ b/src/sincere_singularities/utils.py @@ -29,6 +29,7 @@ class RestaurantJson: name: str icon: str description: str + points: int menu: dict[str, list[str]] From e6b31c37f67900465360a1b614f4d0e921e2fc1f Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Fri, 26 Jul 2024 15:46:24 +0200 Subject: [PATCH 15/43] Order Conditions (#14) * Order Conditions - Implemented Various Order Conditions - Improved Naming - Small Fixes Co-Authored-By: koviubi56 * Fix Debug-Statements, Problems --------- Co-authored-by: koviubi56 --- README.md | 9 +- requirements.txt | 1 + src/sincere_singularities/bot.py | 19 +- .../modules/conditions.py | 235 ++++++++++++++++++ .../modules/order_queue.py | 5 +- .../modules/restaurant.py | 3 + .../modules/restaurants_view.py | 12 +- 7 files changed, 277 insertions(+), 7 deletions(-) create mode 100644 src/sincere_singularities/modules/conditions.py diff --git a/README.md b/README.md index 6d6b2cc..64ef20d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # Game Title Here **Sincere Singularities** Python Discord Summer CodeJam 2024 project. -The technology is **Discord Application**, the theme is **Information Overload**, and our chosen framework is [Disnake](https://github.com/DisnakeDev/disnake/). +The technology is **Discord Application**, the theme is **Information Overload**, and our chosen framework +is [Disnake](https://github.com/DisnakeDev/disnake/). --- @@ -9,7 +10,8 @@ Did you ever want to experience the information overload and stress of a phone o Well, then you're at the right place! We've created a first stressful Discord game, in which you play a phone operator for a restaurant. -No matter what dishes, customers, or special orders you have to serve, don't lose focus, or you might get overwhelmed by the information overload! +No matter what dishes, customers, or special orders you have to serve, don't lose focus, or you might get overwhelmed by +the information overload! ## Running the bot @@ -20,7 +22,8 @@ No matter what dishes, customers, or special orders you have to serve, don't los cd SincereSingularities pip install -e . ``` -3. Setup a [Discord Bot](https://docs.disnake.dev/en/stable/discord.html). +3. Setup + a [Discord Bot](https://docs.disnake.dev/en/stable/discord.html). 4. Set the `BOT_TOKEN` environment variable to your Token using the `.env` file. 5. Run The Game: ```shell diff --git a/requirements.txt b/requirements.txt index f0606a9..a736aec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,5 @@ python-dotenv~=1.0.1 dacite~=1.8.1 torch~=2.3.1 sentence-transformers~=3.0.1 +transformers~=4.43.2 numpy<2 diff --git a/src/sincere_singularities/bot.py b/src/sincere_singularities/bot.py index 4810b48..0401cc4 100644 --- a/src/sincere_singularities/bot.py +++ b/src/sincere_singularities/bot.py @@ -1,11 +1,15 @@ +import asyncio + from disnake import ApplicationCommandInteraction, Intents, TextChannel from disnake.ext import commands +from sincere_singularities.modules.conditions import ConditionManager from sincere_singularities.modules.order_queue import OrderQueue from sincere_singularities.modules.restaurants_view import Restaurants intents = Intents.default() bot = commands.InteractionBot(intents=intents) +background_tasks = set() @bot.slash_command(name="clear_webhooks") @@ -52,7 +56,6 @@ async def clear_threads(inter: ApplicationCommandInteraction) -> None: @bot.slash_command(name="start_game") async def start_game(inter: ApplicationCommandInteraction) -> None: """Main Command of our Game: /start_game""" - # TODO: in the entire game, make sure that buttons can only be clicked on by the user who it was intended for! # Check if the Message was sent in a Text Channel if not isinstance(inter.channel, TextChannel): await inter.response.send_message( @@ -64,8 +67,20 @@ async def start_game(inter: ApplicationCommandInteraction) -> None: order_queue = OrderQueue(inter) # Load Restaurants restaurants = Restaurants(inter, order_queue) + + # Sending Start Menu Embed await inter.response.send_message(embed=restaurants.embeds[0], view=restaurants.view, ephemeral=True) - await order_queue.start_orders() + + # Spawning Orders + await order_queue.spawn_orders() + + # Load ConditionManager (Orders need to be initialized) + condition_manager = ConditionManager(order_queue, restaurants) + restaurants.condition_manager = condition_manager + # Spawning Conditions + task = asyncio.create_task(condition_manager.spawn_conditions()) + background_tasks.add(task) + task.add_done_callback(background_tasks.discard) # Creating Temporary example order from sincere_singularities.modules.order import CustomerInfo, Order diff --git a/src/sincere_singularities/modules/conditions.py b/src/sincere_singularities/modules/conditions.py new file mode 100644 index 0000000..3ecbe07 --- /dev/null +++ b/src/sincere_singularities/modules/conditions.py @@ -0,0 +1,235 @@ +import asyncio +import random +from collections import defaultdict +from contextlib import suppress +from dataclasses import dataclass, field +from enum import StrEnum, auto + +from sincere_singularities.modules.order import CustomerInfo, Order +from sincere_singularities.modules.order_queue import OrderQueue +from sincere_singularities.modules.restaurants_view import Restaurants + +background_tasks = set() + + +class ConditionType(StrEnum): + """Enum Class to Define a ConditionType.""" + + OUT_OF_STOCK_SECTION = auto() + OUT_OF_STOCK_ITEM = auto() + NO_FIRSTNAME = auto() + NO_DELIVERY = auto() + NO_DELIVERY_TIME = auto() + NO_EXTRA_INFORMATION = auto() + + +CONDITIONS_PROBABILITIES = { + ConditionType.OUT_OF_STOCK_SECTION: 0.2, + ConditionType.OUT_OF_STOCK_ITEM: 0.4, + ConditionType.NO_FIRSTNAME: 0.1, + ConditionType.NO_DELIVERY: 0.1, + ConditionType.NO_DELIVERY_TIME: 0.1, + ConditionType.NO_EXTRA_INFORMATION: 0.1, +} + + +@dataclass +class Conditions: + """The Conditions Storage. Formated to be read by Restaurant Name.""" + + # Missing Menu Section. Format: List[Dict[Restaurant_Name, Menu_Section]] + out_of_stock_sections: defaultdict[str, list[str]] = field(default_factory=lambda: defaultdict(list[str])) + # Out of Stock Items. Format: List[Dict[Restaurant_Name, dict[Menu_Section, Menu_Item]]] + out_of_stock_items: defaultdict[str, dict[str, list[str]]] = field( + default_factory=lambda: defaultdict(lambda: defaultdict(list[str])) + ) + # Customer Information Conditions + no_firstname: dict[str, bool] = field(default_factory=lambda: defaultdict(bool)) + no_delivery: dict[str, bool] = field(default_factory=lambda: defaultdict(bool)) + no_delivery_time: dict[str, bool] = field(default_factory=lambda: defaultdict(bool)) + no_extra_information: dict[str, bool] = field(default_factory=lambda: defaultdict(bool)) + + +class ConditionManager: + """Managing the Conditions of each Order (e.g. Pizza is out).""" + + def __init__(self, order_queue: OrderQueue, restaurants: Restaurants) -> None: + self.order_queue = order_queue + self.orders_thread = order_queue.orders_thread + self.webhook = order_queue.webhook + self.restaurants = restaurants + + self.order_conditions = Conditions() + + async def spawn_conditions(self) -> None: + """Constantly Spawn Conditions on the restaurants while the Game is running.""" + while self.order_queue.running: + spawn_interval = random.randint(6, 12) + despawn_interval = float(random.randint(60, 120)) + await asyncio.sleep(spawn_interval) + + condition = random.choices( + population=list(CONDITIONS_PROBABILITIES.keys()), + weights=list(CONDITIONS_PROBABILITIES.values()), + )[0] + + restaurant = random.choice(self.restaurants.restaurants) + menu_section, menu_item = None, None + + if condition in (ConditionType.OUT_OF_STOCK_SECTION, ConditionType.OUT_OF_STOCK_ITEM): + menu_section = random.choice(list(restaurant.menu.keys())) + menu_item = random.choice(restaurant.menu[menu_section]) + + await self.apply_condition(condition, restaurant.name, despawn_interval, menu_section, menu_item) + task = asyncio.create_task( + self.delete_condition(condition, restaurant.name, despawn_interval, menu_section, menu_item) + ) + background_tasks.add(task) + task.add_done_callback(background_tasks.discard) + + async def apply_condition( + self, + condition: ConditionType, + restaurant_name: str, + despawn_interval: float, + menu_section: str | None = None, + menu_item: str | None = None, + ) -> None: + """ + Applies a condition to a Restaurant. + + Args: + condition: The condition to apply to the restaurant. + restaurant_name: The name of the restaurant. + despawn_interval: The amount of time to despawn the condition message. + menu_section: The name of the menu section (OUT_OF_STOCK_SECTION). + menu_item: The name of the menu item (OUT_OF_STOCK_ITEM). + """ + match condition: + case ConditionType.OUT_OF_STOCK_SECTION: + assert menu_section + self.order_conditions.out_of_stock_sections[restaurant_name].append(menu_section) + message = f"{restaurant_name} is out of stock for {menu_section}!" + case ConditionType.OUT_OF_STOCK_ITEM: + assert menu_section + assert menu_item + self.order_conditions.out_of_stock_items[restaurant_name][menu_section].append(menu_item) + message = f"{restaurant_name} is out of stock for {menu_item}!" + case ConditionType.NO_FIRSTNAME: + self.order_conditions.no_firstname[restaurant_name] = True + message = f"For {restaurant_name} orders you shouldn't specify the first name of customers." + case ConditionType.NO_DELIVERY: + self.order_conditions.no_delivery[restaurant_name] = True + message = ( + f"{restaurant_name} doesnt do delivery anymore. \n" + f"Type in `No delivery available` for the address field!" + ) + case ConditionType.NO_DELIVERY_TIME: + self.order_conditions.no_delivery_time[restaurant_name] = True + message = f"For {restaurant_name} orders you shouldn't specify delivery time." + case ConditionType.NO_EXTRA_INFORMATION: + self.order_conditions.no_extra_information[restaurant_name] = True + message = f"For {restaurant_name} orders you shouldn't specify extra information." + + await self.webhook.send( + content=message, + username="🚨 Conditions Alert 🚨", + wait=True, + thread=self.orders_thread, + delete_after=despawn_interval, + ) + + async def delete_condition( + self, + condition: ConditionType, + restaurant_name: str, + despawn_interval: float, + menu_section: str | None = None, + menu_item: str | None = None, + ) -> None: + """ + Deletes a condition from a Restaurant. + + Args: + condition: The condition to delete from the restaurant. + restaurant_name: The name of the restaurant. + despawn_interval: The amount of time to despawn the condition message. + menu_section: The name of the menu section (OUT_OF_STOCK_SECTION). + menu_item: The name of the menu item (OUT_OF_STOCK_ITEM). + """ + await asyncio.sleep(despawn_interval) + + match condition: + case ConditionType.OUT_OF_STOCK_SECTION: + assert menu_section + self.order_conditions.out_of_stock_sections[restaurant_name].remove(menu_section) + case ConditionType.OUT_OF_STOCK_ITEM: + assert menu_section + assert menu_item + self.order_conditions.out_of_stock_items[restaurant_name][menu_section].remove(menu_item) + case ConditionType.NO_FIRSTNAME: + self.order_conditions.no_firstname[restaurant_name] = False + case ConditionType.NO_DELIVERY: + self.order_conditions.no_delivery[restaurant_name] = False + case ConditionType.NO_DELIVERY_TIME: + self.order_conditions.no_delivery_time[restaurant_name] = False + case ConditionType.NO_EXTRA_INFORMATION: + self.order_conditions.no_extra_information[restaurant_name] = False + + def adjust_order_to_conditions(self, order: Order) -> Order: + """ + Adjust the order to conditions. + + Args: + order: The (correct) Order to adjust. + + Returns: + The Adjusted Order. + """ + restaurant_name = order.restaurant_name + assert restaurant_name + + # Deleting the Out-of-Stock Section if necessary + for menu_section in self.order_conditions.out_of_stock_sections[restaurant_name]: + with suppress(KeyError): + del order.foods[menu_section] + + # Deleting the Out-of-Stock Menu Items if necessary + for menu_section, menu_items in self.order_conditions.out_of_stock_items[restaurant_name].items(): + for menu_item in menu_items: + with suppress(KeyError): + order.foods[menu_section].remove(menu_item) + + # Checking the Customer Information Section + assert order.customer_information + adjusted_name = order.customer_information.name + adjusted_address = order.customer_information.address + adjusted_delivery_time = order.customer_information.delivery_time + adjusted_extra_information = order.customer_information.extra_information + # First Name Check + if self.order_conditions.no_firstname.get(restaurant_name): + adjusted_name = adjusted_name.split()[-1] + + # No Delivery Check + if self.order_conditions.no_delivery.get(restaurant_name): + adjusted_address = "No delivery available" + + # No Delivery Time Check + if self.order_conditions.no_delivery_time.get(restaurant_name): + adjusted_delivery_time = "" + + # No Extra Information Check + if self.order_conditions.no_extra_information.get(restaurant_name): + adjusted_extra_information = "" + + # Generating new CustomerInformation + adjusted_customer_information = CustomerInfo( + order_id=order.customer_information.order_id, + name=adjusted_name, + address=adjusted_address, + delivery_time=adjusted_delivery_time, + extra_information=adjusted_extra_information, + ) + order.customer_information = adjusted_customer_information + + return order diff --git a/src/sincere_singularities/modules/order_queue.py b/src/sincere_singularities/modules/order_queue.py index f82a3a4..d21af90 100644 --- a/src/sincere_singularities/modules/order_queue.py +++ b/src/sincere_singularities/modules/order_queue.py @@ -17,8 +17,9 @@ def __init__(self, inter: ApplicationCommandInteraction) -> None: self.user = inter.user self.channel = inter.channel self.orders: dict[str, tuple[Order, WebhookMessage]] = {} + self.running = False - async def start_orders(self) -> None: + async def spawn_orders(self) -> None: """Start the orders queue. Spawn a new Webhook and Order Thread""" # Creating the Order Webhook try: @@ -34,6 +35,7 @@ async def start_orders(self) -> None: name="Orders Thread", type=ChannelType.public_thread, invitable=False ) await self.orders_thread.add_user(self.user) + self.running = True async def create_order(self, customer_name: str, order_message: str, order_result: Order) -> None: """ @@ -81,6 +83,7 @@ async def discard_order(self, order_id: str) -> None: async def stop_orders(self) -> None: """Stop All Orders (when stopping the game).""" + self.running = False # TODO: Make sure these cant fail (or catch specific Errors) with suppress(Exception): # Deleting Webhook diff --git a/src/sincere_singularities/modules/restaurant.py b/src/sincere_singularities/modules/restaurant.py index f1ca81a..c7b0339 100644 --- a/src/sincere_singularities/modules/restaurant.py +++ b/src/sincere_singularities/modules/restaurant.py @@ -67,6 +67,9 @@ def check_order(self, order: Order, correct_order: Order) -> float: Returns: How correct the order was placed in percentage (as a float) """ + # Adjust Order to Conditions + correct_order = self.restaurants.condition_manager.adjust_order_to_conditions(correct_order) + score = 1.0 # The effect on the Score each wrong answer should have # (Length of Menu Items + Customer Information Items + 1 for the restaurant) diff --git a/src/sincere_singularities/modules/restaurants_view.py b/src/sincere_singularities/modules/restaurants_view.py index 203bade..943dcf1 100644 --- a/src/sincere_singularities/modules/restaurants_view.py +++ b/src/sincere_singularities/modules/restaurants_view.py @@ -1,4 +1,5 @@ import random +from typing import TYPE_CHECKING import disnake @@ -7,6 +8,9 @@ from sincere_singularities.modules.restaurant import Restaurant from sincere_singularities.utils import DISNAKE_COLORS, RestaurantJsonType, load_json +if TYPE_CHECKING: + from sincere_singularities.modules.conditions import ConditionManager + class RestaurantPurchaseView(disnake.ui.View): """View subclass for buying a restaurant""" @@ -99,6 +103,8 @@ async def _stop_game(self, *_: disnake.ui.Button | disnake.MessageInteraction) - class Restaurants: """Class to Manage the Restaurants & UI""" + condition_manager: "ConditionManager" + def __init__(self, inter: disnake.ApplicationCommandInteraction, order_queue: OrderQueue) -> None: self.inter = inter self.order_queue = order_queue @@ -171,4 +177,8 @@ def restaurants(self) -> list[Restaurant]: """ # Creating Restaurant Objects Based on the Data - return [Restaurant(self, restaurant) for restaurant in self.restaurants_json] + return [ + Restaurant(self, restaurant) + for restaurant in self.restaurants_json + if has_restaurant(self.inter.user.id, restaurant.name) + ] From 0697bd5a16718e582c51bf80c73b222615e0fd4c Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Fri, 26 Jul 2024 19:53:18 +0200 Subject: [PATCH 16/43] Various Game Improvements (#15) --- pyproject.toml | 4 +- src/sincere_singularities/bot.py | 8 +- .../data/restaurants.json | 300 ++++++++++++++++-- src/sincere_singularities/modules/order.py | 63 +++- .../modules/order_queue.py | 65 +++- .../modules/restaurants_view.py | 9 +- src/sincere_singularities/utils.py | 1 + 7 files changed, 410 insertions(+), 40 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b4698bf..ac782f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,7 +100,9 @@ ignore = [ # Asserts "S101", # Function Arguments - "PLR0913" + "PLR0913", + # UTC Timezones + "DTZ003" ] [tool.ruff.lint.pydocstyle] diff --git a/src/sincere_singularities/bot.py b/src/sincere_singularities/bot.py index 0401cc4..7ff09e3 100644 --- a/src/sincere_singularities/bot.py +++ b/src/sincere_singularities/bot.py @@ -72,7 +72,7 @@ async def start_game(inter: ApplicationCommandInteraction) -> None: await inter.response.send_message(embed=restaurants.embeds[0], view=restaurants.view, ephemeral=True) # Spawning Orders - await order_queue.spawn_orders() + await order_queue.init_orders() # Load ConditionManager (Orders need to be initialized) condition_manager = ConditionManager(order_queue, restaurants) @@ -99,9 +99,9 @@ async def start_game(inter: ApplicationCommandInteraction) -> None: "Please dont ring the bell." ) example_order = Order(customer_information=customer_info, restaurant_name="Pizzaria") - example_order.foods["Starters"].append("Pizza Starter0") - example_order.foods["Starters"].append("Pizza Starter0") - example_order.foods["Main Courses"].append("Main Course0") + example_order.foods["Starters"].append("Garlic Knots") + example_order.foods["Starters"].append("Garlic Knots") + example_order.foods["Main Courses"].append("Veggie Pizza") await order_queue.create_order("Customer Name", example_order_text, example_order) diff --git a/src/sincere_singularities/data/restaurants.json b/src/sincere_singularities/data/restaurants.json index c59e052..ee66cfe 100644 --- a/src/sincere_singularities/data/restaurants.json +++ b/src/sincere_singularities/data/restaurants.json @@ -1,49 +1,307 @@ [ { - "name": "Pizzaria", + "name": "Pizzeria", "icon": ":pizza:", - "description": "Pizzaria Placeholder Description", + "description": "The Pizzaria Restaurant is the starter Restaurant. It offers typical Italian food. The Pizzaria is fairly busy.", "points": 0, + "order_amount": 3, "menu": { "Starters": [ - "Pizza Starter0", - "Pizza Starter1" + "Garlic Knots", + "Bruschetta", + "Caprese Salad", + "Stuffed Mushrooms", + "Fried Calamari", + "Mozzarella Sticks", + "Antipasto", + "Spinach Artichoke Dip" ], "Main Courses": [ - "Main Course0", - "Pizza Main Course1" + "Margherita Pizza", + "Pepperoni Pizza", + "BBQ Chicken Pizza", + "Hawaiian Pizza", + "Veggie Pizza", + "Meat Lovers Pizza", + "White Pizza", + "Buffalo Chicken Pizza", + "Four Cheese Pizza", + "Sausage and Peppers Pizza" ], "Desserts": [ - "Pizza Dessert0", - "Pizza Dessert1" + "Tiramisu", + "Cannoli", + "Gelato", + "Panna Cotta", + "Zeppole", + "Chocolate Pizza" ], "Drinks": [ - "Pizza Drink0", - "Pizza Drink1" + "Soda", + "Iced Tea", + "Lemonade", + "Beer", + "Red Wine", + "White Wine", + "Sparkling Water", + "Espresso", + "Cappuccino", + "Limoncello" ] } }, { - "name": "Sushi Restaurant", + "name": "Fast Food", + "icon": ":hamburger:", + "description": "The Fast Food restaurant offers typically American foods including hamburgers and fries. It is very busy at all times.", + "points": 25, + "order_amount": 4, + "menu": { + "Starters": [ + "French Fries", + "Onion Rings", + "Mozzarella Sticks", + "Chicken Nuggets", + "Cheese Curds", + "Garlic Bread", + "Jalapeno Poppers", + "Loaded Potato Skins" + ], + "Main Courses": [ + "Cheeseburger", + "Hot Dog", + "Chicken Sandwich", + "Veggie Burger", + "Bacon Cheeseburger", + "Fish Sandwich", + "Grilled Chicken Wrap", + "Philly Cheesesteak", + "Pulled Pork Sandwich", + "Double Cheeseburger" + ], + "Desserts": [ + "Ice Cream Cone", + "Apple Pie", + "Chocolate Sundae", + "Milkshake", + "Brownie", + "Soft Serve Ice Cream" + ], + "Drinks": [ + "Soda", + "Lemonade", + "Iced Tea", + "Milkshake", + "Water", + "Coffee", + "Hot Chocolate", + "Orange Juice", + "Apple Juice", + "Grape Soda" + ] + } + }, + { + "name": "Meat", + "icon": ":cut_of_meat:", + "description": "The Meat Restaurant focuses on a wide variety of meats. But it also has some delicious desserts! You won't get that many orders for here.", + "points": 50, + "order_amount": 2, + "menu": { + "Starters": [ + "Buffalo Wings", + "Beef Carpaccio", + "Prosciutto and Melon", + "Lamb Skewers", + "Chicken Livers", + "Pork Belly Bites", + "Meatballs", + "Sausage Rolls" + ], + "Main Courses": [ + "Steak", + "Ribs", + "Roast Chicken", + "Pork Chops", + "Lamb Chops", + "BBQ Brisket", + "Pulled Pork", + "Veal Parmesan", + "Beef Stroganoff", + "Chicken Alfredo" + ], + "Desserts": [ + "Chocolate Lava Cake", + "Cheesecake", + "Brownie Sundae", + "Apple Pie", + "Tiramisu", + "Bread Pudding" + ], + "Drinks": [ + "Red Wine", + "Beer", + "Whiskey", + "Iced Tea", + "Old Fashioned", + "Manhattan", + "Martini", + "Negroni", + "Rum", + "Cognac" + ] + } + }, + { + "name": "Sushi", "icon": ":sushi:", - "description": "Sushi Placeholder Description", - "points": 300, + "description": "The Sushi restaurant offers sushi and other typically Japanese dishes. Not too many people order Sushi.", + "points": 50, + "order_amount": 2, + "menu": { + "Starters": [ + "Edamame", + "Miso Soup", + "Seaweed Salad", + "Gyoza", + "Shumai", + "Agedashi Tofu", + "Tuna Tataki", + "Cucumber Salad" + ], + "Main Courses": [ + "California Roll", + "Spicy Tuna Roll", + "Salmon Nigiri", + "Eel Avocado Roll", + "Rainbow Roll", + "Dragon Roll", + "Tempura Roll", + "Shrimp Nigiri", + "Philadelphia Roll", + "Vegetable Roll" + ], + "Desserts": [ + "Mochi Ice Cream", + "Green Tea Ice Cream", + "Red Bean Ice Cream", + "Dorayaki", + "Matcha Cheesecake", + "Tempura Banana" + ], + "Drinks": [ + "Green Tea", + "Sake", + "Plum Wine", + "Ramune", + "Asahi Beer", + "Sapporo Beer", + "Oolong Tea", + "Mugicha", + "Calpico", + "Yuzu Juice" + ] + } + }, + { + "name": "Seafood", + "icon": ":shrimp:", + "description": "In this restaurant you can find everything the heart desires from the sea. This restaurant isn't as busy.", + "points": 75, + "order_amount": 1, + "menu": { + "Starters": [ + "Clam Chowder", + "Shrimp Cocktail", + "Calamari", + "Oysters Rockefeller", + "Crab Cakes", + "Lobster Bisque", + "Ceviche", + "Mussels in White Wine Sauce" + ], + "Main Courses": [ + "Grilled Salmon", + "Fish and Chips", + "Lobster Tail", + "Seafood Paella", + "Crab Legs", + "Shrimp Scampi", + "Blackened Catfish", + "Tuna Steak", + "Seafood Alfredo", + "Baked Cod" + ], + "Desserts": [ + "Key Lime Pie", + "Sea Salt Caramel Cheesecake", + "Lemon Sorbet", + "Coconut Cream Pie", + "Pineapple Upside-Down Cake", + "Mango Sorbet" + ], + "Drinks": [ + "White Wine", + "Sparkling Water", + "Margarita", + "Lemonade", + "Beer", + "Rum Punch", + "Bloody Mary", + "Champagne", + "Sangria", + "Mojito" + ] + } + }, + { + "name": "Chinese", + "icon": ":fortune_cookie:", + "description": "The Chinese Restaurant serves Chinese cuisine like dumplings and fortune cookies. This restaurant can be busy at times.", + "points": 75, + "order_amount": 3, "menu": { "Starters": [ - "Sushi Starter0", - "Sushi Starter1" + "Spring Rolls", + "Dumplings", + "Hot and Sour Soup", + "Chicken Satay", + "Egg Drop Soup", + "Fried Wontons", + "Crab Rangoon", + "Lettuce Wraps" ], "Main Courses": [ - "Sushi Main Course0", - "Sushi Main Course1" + "Kung Pao Chicken", + "Sweet and Sour Pork", + "Beef with Broccoli", + "Vegetable Chow Mein", + "General Tso's Chicken", + "Orange Chicken", + "Moo Shu Pork", + "Shrimp Fried Rice", + "Chicken Fried Rice", + "Beef Fried Rice" ], "Desserts": [ - "Sushi Dessert0", - "Sushi Dessert1" + "Fortune Cookies", + "Mango Pudding", + "Egg Tarts", + "Sesame Balls", + "Red Bean Paste Buns", + "Almond Cookies" ], "Drinks": [ - "Drink0", - "Drink1" + "Green Tea", + "Jasmine Tea", + "Soy Milk", + "Plum Juice", + "Bubble Tea", + "Oolong Tea", + "Chrysanthemum Tea", + "Tsingtao Beer", + "Chinese Herbal Tea", + "Rice Wine" ] } } diff --git a/src/sincere_singularities/modules/order.py b/src/sincere_singularities/modules/order.py index cd061f4..f375ad5 100644 --- a/src/sincere_singularities/modules/order.py +++ b/src/sincere_singularities/modules/order.py @@ -1,6 +1,8 @@ +import random from collections import defaultdict from collections.abc import Iterable from dataclasses import dataclass, field +from datetime import datetime, timedelta from typing import TYPE_CHECKING import disnake @@ -32,6 +34,12 @@ class Order: restaurant_name: str | None = None foods: defaultdict[str, list[str]] = field(default_factory=lambda: defaultdict(list[str])) + def __post_init__(self) -> None: + # Recalculate the Order Timestamp to match Initialization + self.order_timestamp = datetime.utcnow() # Timezone doesnt matter + self.penalty_seconds = random.randint(4 * 60, 6 * 60) + self.penalty_timestamp = self.order_timestamp + timedelta(seconds=self.penalty_seconds) + class CustomerInfoModal(disnake.ui.Modal): """The Modal for entering Customer Information.""" @@ -191,11 +199,27 @@ async def _order_done(self, _: disnake.ui.Button, inter: disnake.MessageInteract await inter.response.edit_message(embed=embed, view=self) return - user_order = self.restaurant.order_queue.get_order_by_id(self.order.customer_information.order_id) - assert user_order - correctness = self.restaurant.check_order(self.order, user_order) + # Getting the correct order + correct_order = self.restaurant.order_queue.get_order_by_id(self.order.customer_information.order_id) + assert correct_order + + # Calculating Correctness and Discarding Order + correctness = self.restaurant.check_order(self.order, correct_order) await self.restaurant.order_queue.discard_order(self.order.customer_information.order_id) points = round(correctness * 10) # 100% -> 10p + + # Checking how long Order Completion took + assert correct_order.order_timestamp + time_taken = (datetime.utcnow() - correct_order.order_timestamp).total_seconds() # Timezone doesnt matter + bonus_interval = 60 # (seconds) + completion_message = "" + if time_taken <= bonus_interval: + points += 5 + completion_message = "You've completed the order in under a minute and get 5 bonus points! \n" + elif time_taken >= correct_order.penalty_seconds: + points -= 5 + completion_message = "You've took to long to complete the order and receive a 5 points penalty! \n" + add_points(inter.user.id, points) # Adding Info to embed @@ -204,8 +228,37 @@ async def _order_done(self, _: disnake.ui.Button, inter: disnake.MessageInteract embed.insert_field_at( index=1, name=":loudspeaker: :white_check_mark: Info :white_check_mark: :loudspeaker:", - value=f"**Order placed successfully! Correctness: {round(correctness, 4)*100}% You gained {points} points;" - f" you now have {get_points(inter.user.id)}!**", + value=f"**Order placed successfully! Correctness: {round(correctness, 4) * 100}%.\n {completion_message}" + f"You gained {points} points; you now have {get_points(inter.user.id)}!**", inline=False, ) await inter.response.edit_message(embed=embed, view=self.restaurant.restaurants.view) + + @disnake.ui.button(label="Show conditions", style=disnake.ButtonStyle.secondary, row=2) + async def _show_conditions(self, _: disnake.ui.Button, inter: disnake.Message.Interaction) -> None: + embed = disnake.Embed(title="Current conditions", color=disnake.Color.blue()) + condition = self.restaurant.restaurants.condition_manager.order_conditions + if menu_section := condition.out_of_stock_sections.get(self.restaurant.name): + embed.add_field( + "Out of stock menu sections", + f"The following menu sections are out of stock: {', '.join(menu_section)}", + inline=False, + ) + if sections := condition.out_of_stock_items.get(self.restaurant.name): + out_of_stock_items = ", ".join([item for menu in sections.values() for item in menu]) + embed.add_field( + "Out of stock menu items", + f"The following menu items are out of stock: {out_of_stock_items}", + inline=False, + ) + if condition.no_firstname.get(self.restaurant.name): + embed.add_field("No firstname", "You shouldn't specify the first names of the customers.", inline=False) + if condition.no_delivery.get(self.restaurant.name): + embed.add_field("No delivery", "Type in `No delivery available` for the address field.", inline=False) + if condition.no_delivery_time.get(self.restaurant.name): + embed.add_field("No delivery time", "You shouldn't specify the delivery time.", inline=False) + if condition.no_extra_information.get(self.restaurant.name): + embed.add_field("No extra information", "You shouldn't specify extra informations.", inline=False) + if not embed.fields: + embed.description = "No conditions at this time." + await inter.response.send_message(ephemeral=True, embed=embed) diff --git a/src/sincere_singularities/modules/order_queue.py b/src/sincere_singularities/modules/order_queue.py index d21af90..4238809 100644 --- a/src/sincere_singularities/modules/order_queue.py +++ b/src/sincere_singularities/modules/order_queue.py @@ -1,10 +1,32 @@ +import asyncio +import random from contextlib import suppress -from disnake import ApplicationCommandInteraction, ChannelType, Thread, Webhook, WebhookMessage +from disnake import ( + ApplicationCommandInteraction, + ChannelType, + HTTPException, + NotFound, + Thread, + Webhook, + WebhookMessage, +) from disnake.ext.commands.errors import CommandInvokeError from sincere_singularities.modules.order import Order -from sincere_singularities.utils import generate_random_avatar_url +from sincere_singularities.modules.points import has_restaurant +from sincere_singularities.utils import RestaurantJson, RestaurantJsonType, generate_random_avatar_url, load_json + +# Temporary +ORDER_TEMPLATES = [ + ( + "Hello, I'd like to place an order for delivery. My name is {CUSTOMER_NAME}, and I live at {CUSTOMER_ADDRESS}." + " I'd like to order {ORDER_ITEMS}. Oh, and by the way, I have a cat named Fluffy and I don't like it when" + "people ring the doorbell, so please make sure to just knock politely. Please deliver it at {DELIVERY_TIME}. " + "Thank you.", + "Don't ring the bell", + ) +] class OrderQueue: @@ -18,8 +40,9 @@ def __init__(self, inter: ApplicationCommandInteraction) -> None: self.channel = inter.channel self.orders: dict[str, tuple[Order, WebhookMessage]] = {} self.running = False + self.restaurant_json = load_json("restaurants.json", RestaurantJsonType) - async def spawn_orders(self) -> None: + async def init_orders(self) -> None: """Start the orders queue. Spawn a new Webhook and Order Thread""" # Creating the Order Webhook try: @@ -37,6 +60,31 @@ async def spawn_orders(self) -> None: await self.orders_thread.add_user(self.user) self.running = True + # Spawn 3 Orders at the start, which get refreshed after one order is done + for _ in range(3): + await self.spawn_order() + + async def spawn_order(self) -> None: + """Spawning a new randomly genrated Order""" + if not self.running: + return + + # Filtering out the Restaurants the user has + restaurants: list[RestaurantJson] = [ + restaurant for restaurant in self.restaurant_json if has_restaurant(self.user.id, restaurant.name) + ] + + # Calculate the Order Amounts to relative values + order_amounts_sum: int = sum(restaurant.order_amount for restaurant in restaurants) + relative_order_amounts: list[float] = [ + restaurant.order_amount / order_amounts_sum for restaurant in restaurants + ] + + # Getting a random restaurant wheighed by their relative order amounts + random_restaurant = random.choices(population=restaurants, weights=relative_order_amounts)[0] + print(random_restaurant) + # TODO: Implement Cluckers Algo + async def create_order(self, customer_name: str, order_message: str, order_result: Order) -> None: """ Create a new Order, sends a message to the Discord and stores the result to check. @@ -46,6 +94,8 @@ async def create_order(self, customer_name: str, order_message: str, order_resul order_message: The message to send to the Discord channel. order_result: The correct result the Order should give """ + discord_tz = f"" + order_message += f" The order should be completed within {discord_tz} seconds or you will get a penalty!" order_message = await self.webhook.send( content=order_message, username=customer_name, @@ -81,12 +131,17 @@ async def discard_order(self, order_id: str) -> None: with suppress(KeyError): del self.orders[order_id] + # Wait 10-20 Seconds as an order Cooldown + order_timeout = random.randint(10, 20) + await asyncio.sleep(order_timeout) + await self.spawn_order() + async def stop_orders(self) -> None: """Stop All Orders (when stopping the game).""" self.running = False - # TODO: Make sure these cant fail (or catch specific Errors) - with suppress(Exception): + with suppress(HTTPException, NotFound): # Deleting Webhook await self.webhook.delete() + with suppress(HTTPException, NotFound): # Deleting Orders Thread await self.orders_thread.delete() diff --git a/src/sincere_singularities/modules/restaurants_view.py b/src/sincere_singularities/modules/restaurants_view.py index 943dcf1..0f43ad6 100644 --- a/src/sincere_singularities/modules/restaurants_view.py +++ b/src/sincere_singularities/modules/restaurants_view.py @@ -6,7 +6,7 @@ from sincere_singularities.modules.order_queue import OrderQueue from sincere_singularities.modules.points import buy_restaurant, get_points, has_restaurant from sincere_singularities.modules.restaurant import Restaurant -from sincere_singularities.utils import DISNAKE_COLORS, RestaurantJsonType, load_json +from sincere_singularities.utils import DISNAKE_COLORS if TYPE_CHECKING: from sincere_singularities.modules.conditions import ConditionManager @@ -88,8 +88,9 @@ async def _next_page(self, _: disnake.ui.Button, inter: disnake.MessageInteracti @disnake.ui.button(label="Pause Orders", style=disnake.ButtonStyle.secondary, row=1) async def _pause_orders(self, *_: disnake.ui.Button | disnake.MessageInteraction) -> None: - # TODO: Pause Orders - # TODO: Fix awful typing when implemented + # Placebo Button. Doesnt do anything but looks nice (to give the user feeling of control.) + # This button doesnt do anything because the game ensure the user has 3 orders at all times, so you wont get + # more than 3 orders anyway, and they dont run out return @disnake.ui.button(label="Stop the Game", style=disnake.ButtonStyle.danger, row=1) @@ -109,7 +110,7 @@ def __init__(self, inter: disnake.ApplicationCommandInteraction, order_queue: Or self.inter = inter self.order_queue = order_queue # Loading Restaurants - self.restaurants_json = load_json("restaurants.json", RestaurantJsonType) + self.restaurants_json = order_queue.restaurant_json @property def view(self) -> RestaurantsView: diff --git a/src/sincere_singularities/utils.py b/src/sincere_singularities/utils.py index 7051ba1..8b80391 100644 --- a/src/sincere_singularities/utils.py +++ b/src/sincere_singularities/utils.py @@ -30,6 +30,7 @@ class RestaurantJson: icon: str description: str points: int + order_amount: int menu: dict[str, list[str]] From 425c5e685552cc6ce306d579ea3ae87e957c8c3e Mon Sep 17 00:00:00 2001 From: koviubi56 Date: Sat, 27 Jul 2024 13:14:53 +0200 Subject: [PATCH 17/43] Refactoring (#16) - Add better comments and docstrings. - Fix some errors. - Better type hinting. - Other misc. refactoring. Signed-off-by: koviubi56 --- pyproject.toml | 2 - src/sincere_singularities/bot.py | 116 +++++---- .../modules/conditions.py | 159 +++++++----- src/sincere_singularities/modules/order.py | 237 ++++++++++++------ .../modules/order_queue.py | 119 +++++---- src/sincere_singularities/modules/points.py | 2 +- .../modules/restaurant.py | 63 +++-- .../modules/restaurants_view.py | 135 +++++----- src/sincere_singularities/utils.py | 29 ++- 9 files changed, 521 insertions(+), 341 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ac782f3..94c241a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,8 +101,6 @@ ignore = [ "S101", # Function Arguments "PLR0913", - # UTC Timezones - "DTZ003" ] [tool.ruff.lint.pydocstyle] diff --git a/src/sincere_singularities/bot.py b/src/sincere_singularities/bot.py index 7ff09e3..9416c61 100644 --- a/src/sincere_singularities/bot.py +++ b/src/sincere_singularities/bot.py @@ -1,6 +1,7 @@ import asyncio +from typing import cast -from disnake import ApplicationCommandInteraction, Intents, TextChannel +from disnake import ApplicationCommandInteraction, Intents, Member, TextChannel from disnake.ext import commands from sincere_singularities.modules.conditions import ConditionManager @@ -9,83 +10,108 @@ intents = Intents.default() bot = commands.InteractionBot(intents=intents) -background_tasks = set() +# This global set is used to ensure that a (non-weak) reference is kept to background tasks created that aren't +# awaited. These tasks get added to this set, then once they're done, they remove themselves. +# See RUF006 +background_tasks: set[asyncio.Task[None]] = set() @bot.slash_command(name="clear_webhooks") -async def clear_webhooks(inter: ApplicationCommandInteraction) -> None: - """Clears the webhoooks in a channel.""" - # Check user permissions - permissions = inter.channel.permissions_for(inter.author) - if not permissions.manage_webhooks: - await inter.response.send_message("You dont have the permissions to Manage Webhooks!", ephemeral=True) +async def clear_webhooks(interaction: ApplicationCommandInteraction) -> None: + """ + Clears the webhooks in a channel. + + Args: + interaction (ApplicationCommandInteraction): The Disnake application command interaction. + """ + # Check if the message was sent in a text channel + if not isinstance(interaction.channel, TextChannel): + await interaction.response.send_message( + "I'm only able to clear webhooks inside of a text channel!", ephemeral=True + ) return - # Check if the Message was sent in a Text Channel - if not isinstance(inter.channel, TextChannel): - await inter.response.send_message("Im only able to clear webhooks inside of a Text Channel!", ephemeral=True) + # Check user permissions + # We know this is a Member because interaction.channel is a guild text channel + permissions = interaction.channel.permissions_for(cast(Member, interaction.author)) + if not permissions.manage_webhooks: + await interaction.response.send_message("You don't have the permissions to manage webhooks!", ephemeral=True) return - webhooks = await inter.channel.webhooks() + webhooks = await interaction.channel.webhooks() for webhook in webhooks: await webhook.delete() - await inter.response.send_message("Webhooks cleared!", ephemeral=True) + await interaction.response.send_message("Webhooks cleared!", ephemeral=True) @bot.slash_command(name="clear_threads") -async def clear_threads(inter: ApplicationCommandInteraction) -> None: - """Clears the threads in a channel.""" - # Check user permissions - permissions = inter.channel.permissions_for(inter.author) - if not permissions.manage_threads: - await inter.response.send_message("You dont have the permissions to Manage Threads!", ephemeral=True) +async def clear_threads(interaction: ApplicationCommandInteraction) -> None: + """ + Clears the threads in a channel. + + Args: + interaction (ApplicationCommandInteraction): The Disnake application command interaction. + """ + # Check if the message was sent in a text channel + if not isinstance(interaction.channel, TextChannel): + await interaction.response.send_message( + "I'm only able to clear threads inside of a text channel!", ephemeral=True + ) return - # Check if the Message was sent in a Text Channel - if not isinstance(inter.channel, TextChannel): - await inter.response.send_message("Im only able to clear threads inside of a Text Channel!", ephemeral=True) + # Check user permissions + # We know this is a Member because interaction.channel is a guild text channel + permissions = interaction.channel.permissions_for(cast(Member, interaction.author)) + if not permissions.manage_threads: + await interaction.response.send_message("You don't have the permissions to manage threads!", ephemeral=True) return - for thread in inter.channel.threads: + for thread in interaction.channel.threads: await thread.delete() - await inter.response.send_message("Threads cleared!", ephemeral=True) + await interaction.response.send_message("Threads cleared!", ephemeral=True) @bot.slash_command(name="start_game") -async def start_game(inter: ApplicationCommandInteraction) -> None: - """Main Command of our Game: /start_game""" - # Check if the Message was sent in a Text Channel - if not isinstance(inter.channel, TextChannel): - await inter.response.send_message( - "You can only start a Game Session inside of a Text Channel!", ephemeral=True +async def start_game(interaction: ApplicationCommandInteraction) -> None: + """ + Start the game. + + Args: + interaction (ApplicationCommandInteraction): The Disnake application command interaction. + """ + # Check if the message was sent in a text channel + if not isinstance(interaction.channel, TextChannel): + await interaction.response.send_message( + "You can only start a game session inside of a text channel!", ephemeral=True ) return - # Start Order Queue - order_queue = OrderQueue(inter) + # Start order queue + order_queue = await OrderQueue.new(interaction) + if not order_queue: + # Return if we can't start the game (the user is already warned) + return # Load Restaurants - restaurants = Restaurants(inter, order_queue) + condition_manager = ConditionManager(order_queue) + restaurants = Restaurants(interaction, order_queue, condition_manager) - # Sending Start Menu Embed - await inter.response.send_message(embed=restaurants.embeds[0], view=restaurants.view, ephemeral=True) + # Sending start menu + await interaction.response.send_message(embed=restaurants.embeds[0], view=restaurants.view, ephemeral=True) - # Spawning Orders - await order_queue.init_orders() + # Spawning orders + await order_queue.start_orders() - # Load ConditionManager (Orders need to be initialized) - condition_manager = ConditionManager(order_queue, restaurants) - restaurants.condition_manager = condition_manager - # Spawning Conditions + # Spawning conditions task = asyncio.create_task(condition_manager.spawn_conditions()) background_tasks.add(task) task.add_done_callback(background_tasks.discard) - # Creating Temporary example order - from sincere_singularities.modules.order import CustomerInfo, Order + # Creating temporary example order + from sincere_singularities.modules.order import CustomerInformation, Order - customer_info = CustomerInfo( + customer_info = CustomerInformation( order_id="Test123", name="Customer Name", address="Customer Address", @@ -103,7 +129,7 @@ async def start_game(inter: ApplicationCommandInteraction) -> None: example_order.foods["Starters"].append("Garlic Knots") example_order.foods["Main Courses"].append("Veggie Pizza") - await order_queue.create_order("Customer Name", example_order_text, example_order) + await order_queue.create_order(example_order_text, example_order) @bot.event diff --git a/src/sincere_singularities/modules/conditions.py b/src/sincere_singularities/modules/conditions.py index 3ecbe07..e9ba904 100644 --- a/src/sincere_singularities/modules/conditions.py +++ b/src/sincere_singularities/modules/conditions.py @@ -5,24 +5,34 @@ from dataclasses import dataclass, field from enum import StrEnum, auto -from sincere_singularities.modules.order import CustomerInfo, Order +from sincere_singularities.modules.order import CustomerInformation, Order from sincere_singularities.modules.order_queue import OrderQueue -from sincere_singularities.modules.restaurants_view import Restaurants +from sincere_singularities.utils import RestaurantJsonType, load_json -background_tasks = set() +# This global set is used to ensure that a (non-weak) reference is kept to background tasks created that aren't +# awaited. These tasks get added to this set, then once they're done, they remove themselves. +# See RUF006 +background_tasks: set[asyncio.Task[None]] = set() class ConditionType(StrEnum): - """Enum Class to Define a ConditionType.""" + """Enum class for different conditions.""" + # An entire menu section (e.g. Main Courses) is out of stock. OUT_OF_STOCK_SECTION = auto() + # A menu item (e.g. Lemonade) is out of stock. OUT_OF_STOCK_ITEM = auto() + # The user shouldn't specify the firstnames of customers. NO_FIRSTNAME = auto() + # There are no deliveries available. "No delivery available" should be put in the address field. NO_DELIVERY = auto() + # The delivery time shouldn't be specified. NO_DELIVERY_TIME = auto() + # Extra informations shouldn't be specified. NO_EXTRA_INFORMATION = auto() +# The probabilities of each condition happening. The numbers should add up to 1. CONDITIONS_PROBABILITIES = { ConditionType.OUT_OF_STOCK_SECTION: 0.2, ConditionType.OUT_OF_STOCK_ITEM: 0.4, @@ -33,56 +43,69 @@ class ConditionType(StrEnum): } -@dataclass +@dataclass(slots=True) class Conditions: - """The Conditions Storage. Formated to be read by Restaurant Name.""" + """The conditions storage. Every dictionary's keys are restaurant names.""" - # Missing Menu Section. Format: List[Dict[Restaurant_Name, Menu_Section]] + # Out of stock menu section. Format: Dict[Restaurant_Name, List[Menu_Section]] out_of_stock_sections: defaultdict[str, list[str]] = field(default_factory=lambda: defaultdict(list[str])) - # Out of Stock Items. Format: List[Dict[Restaurant_Name, dict[Menu_Section, Menu_Item]]] + # Out of stock items. Format: Dict[Restaurant_Name, dict[Menu_Section, List[Menu_Item]]] out_of_stock_items: defaultdict[str, dict[str, list[str]]] = field( default_factory=lambda: defaultdict(lambda: defaultdict(list[str])) ) - # Customer Information Conditions - no_firstname: dict[str, bool] = field(default_factory=lambda: defaultdict(bool)) - no_delivery: dict[str, bool] = field(default_factory=lambda: defaultdict(bool)) - no_delivery_time: dict[str, bool] = field(default_factory=lambda: defaultdict(bool)) - no_extra_information: dict[str, bool] = field(default_factory=lambda: defaultdict(bool)) + # Customer information conditions + no_firstname: defaultdict[str, bool] = field(default_factory=lambda: defaultdict(bool)) + no_delivery: defaultdict[str, bool] = field(default_factory=lambda: defaultdict(bool)) + no_delivery_time: defaultdict[str, bool] = field(default_factory=lambda: defaultdict(bool)) + no_extra_information: defaultdict[str, bool] = field(default_factory=lambda: defaultdict(bool)) class ConditionManager: - """Managing the Conditions of each Order (e.g. Pizza is out).""" + """Managing the conditions of restaurants.""" - def __init__(self, order_queue: OrderQueue, restaurants: Restaurants) -> None: + def __init__(self, order_queue: OrderQueue) -> None: + """ + Initialize the condition manager. + + Args: + order_queue (OrderQueue): The order queue. + restaurants (Restaurants): The restaurants. + """ self.order_queue = order_queue self.orders_thread = order_queue.orders_thread self.webhook = order_queue.webhook - self.restaurants = restaurants + self.user_id = order_queue.user.id + self.restaurants_json = load_json("restaurants.json", RestaurantJsonType) self.order_conditions = Conditions() async def spawn_conditions(self) -> None: - """Constantly Spawn Conditions on the restaurants while the Game is running.""" + """Constantly spawn conditions on the restaurants while the game is running.""" while self.order_queue.running: - spawn_interval = random.randint(6, 12) - despawn_interval = float(random.randint(60, 120)) - await asyncio.sleep(spawn_interval) + spawn_sleep_seconds = random.randint(6, 12) + despawn_sleep_seconds = float(random.randint(60, 120)) + await asyncio.sleep(spawn_sleep_seconds) + # Choose a random condition with the provided probabilities. condition = random.choices( population=list(CONDITIONS_PROBABILITIES.keys()), weights=list(CONDITIONS_PROBABILITIES.values()), )[0] + # Choose a random restaurant + restaurant = random.choice(self.restaurants_json) - restaurant = random.choice(self.restaurants.restaurants) + # Choose a menu section and item if needed. menu_section, menu_item = None, None - if condition in (ConditionType.OUT_OF_STOCK_SECTION, ConditionType.OUT_OF_STOCK_ITEM): menu_section = random.choice(list(restaurant.menu.keys())) - menu_item = random.choice(restaurant.menu[menu_section]) + if condition == ConditionType.OUT_OF_STOCK_ITEM: + menu_item = random.choice(restaurant.menu[menu_section]) - await self.apply_condition(condition, restaurant.name, despawn_interval, menu_section, menu_item) + # Apply the condition. + await self.apply_condition(condition, restaurant.name, despawn_sleep_seconds, menu_section, menu_item) + # Create and store the created delete task. task = asyncio.create_task( - self.delete_condition(condition, restaurant.name, despawn_interval, menu_section, menu_item) + self.delete_condition(condition, restaurant.name, despawn_sleep_seconds, menu_section, menu_item) ) background_tasks.add(task) task.add_done_callback(background_tasks.discard) @@ -91,28 +114,31 @@ async def apply_condition( self, condition: ConditionType, restaurant_name: str, - despawn_interval: float, + despawn_seconds: float, menu_section: str | None = None, menu_item: str | None = None, ) -> None: """ - Applies a condition to a Restaurant. + Applies a condition to a restaurant. Args: - condition: The condition to apply to the restaurant. - restaurant_name: The name of the restaurant. - despawn_interval: The amount of time to despawn the condition message. - menu_section: The name of the menu section (OUT_OF_STOCK_SECTION). - menu_item: The name of the menu item (OUT_OF_STOCK_ITEM). + condition (ConditionType): The condition to apply to the restaurant. + restaurant_name (str): The name of the restaurant. + despawn_seconds (float): The amount of time in seconds to delete the condition message after sending. + menu_section (str | None, optional): The name of the menu section (OUT_OF_STOCK_SECTION). Defaults to None. + menu_item (str | None, optional): The name of the menu item (OUT_OF_STOCK_ITEM). Defaults to None. """ match condition: case ConditionType.OUT_OF_STOCK_SECTION: - assert menu_section + if not menu_section: + raise ValueError("missing menu_section") self.order_conditions.out_of_stock_sections[restaurant_name].append(menu_section) message = f"{restaurant_name} is out of stock for {menu_section}!" case ConditionType.OUT_OF_STOCK_ITEM: - assert menu_section - assert menu_item + if not menu_section: + raise ValueError("missing menu_section") + if not menu_item: + raise ValueError("missing menu_item") self.order_conditions.out_of_stock_items[restaurant_name][menu_section].append(menu_item) message = f"{restaurant_name} is out of stock for {menu_item}!" case ConditionType.NO_FIRSTNAME: @@ -121,7 +147,7 @@ async def apply_condition( case ConditionType.NO_DELIVERY: self.order_conditions.no_delivery[restaurant_name] = True message = ( - f"{restaurant_name} doesnt do delivery anymore. \n" + f"{restaurant_name} doesn't do delivery anymore. \n" f"Type in `No delivery available` for the address field!" ) case ConditionType.NO_DELIVERY_TIME: @@ -136,36 +162,39 @@ async def apply_condition( username="🚨 Conditions Alert 🚨", wait=True, thread=self.orders_thread, - delete_after=despawn_interval, + delete_after=despawn_seconds, ) async def delete_condition( self, condition: ConditionType, restaurant_name: str, - despawn_interval: float, + despawn_seconds: float, menu_section: str | None = None, menu_item: str | None = None, ) -> None: """ - Deletes a condition from a Restaurant. + Deletes a condition from a restaurant after waiting. Args: - condition: The condition to delete from the restaurant. - restaurant_name: The name of the restaurant. - despawn_interval: The amount of time to despawn the condition message. - menu_section: The name of the menu section (OUT_OF_STOCK_SECTION). - menu_item: The name of the menu item (OUT_OF_STOCK_ITEM). + condition (ConditionType): The condition to delete from the restaurant. + restaurant_name (str): The name of the restaurant. + despawn_seconds (float): The amount of time in seconds to wait before deleting the condition. + menu_section (str | None, optional): The name of the menu section (OUT_OF_STOCK_SECTION). Defaults to None. + menu_item (str | None, optional): The name of the menu item (OUT_OF_STOCK_ITEM). Defaults to None. """ - await asyncio.sleep(despawn_interval) + await asyncio.sleep(despawn_seconds) match condition: case ConditionType.OUT_OF_STOCK_SECTION: - assert menu_section + if not menu_section: + raise ValueError("missing menu_section") self.order_conditions.out_of_stock_sections[restaurant_name].remove(menu_section) case ConditionType.OUT_OF_STOCK_ITEM: - assert menu_section - assert menu_item + if not menu_section: + raise ValueError("missing menu_section") + if not menu_item: + raise ValueError("missing menu_item") self.order_conditions.out_of_stock_items[restaurant_name][menu_section].remove(menu_item) case ConditionType.NO_FIRSTNAME: self.order_conditions.no_firstname[restaurant_name] = False @@ -178,52 +207,58 @@ async def delete_condition( def adjust_order_to_conditions(self, order: Order) -> Order: """ - Adjust the order to conditions. + Adjust the order to the current conditions. + + For example, if the current condition is `NO_FIRSTNAME`, then this function removes the firstname from this + order. Args: - order: The (correct) Order to adjust. + order (Order): The (correct) order to adjust. Returns: - The Adjusted Order. + Order: The adjusted order. """ restaurant_name = order.restaurant_name - assert restaurant_name + if not restaurant_name: + raise ValueError("missing restaurant_name") - # Deleting the Out-of-Stock Section if necessary + # Deleting the out-of-stock section if necessary for menu_section in self.order_conditions.out_of_stock_sections[restaurant_name]: with suppress(KeyError): del order.foods[menu_section] - # Deleting the Out-of-Stock Menu Items if necessary + # Deleting the out-of-stock menu items if necessary for menu_section, menu_items in self.order_conditions.out_of_stock_items[restaurant_name].items(): for menu_item in menu_items: - with suppress(KeyError): + with suppress(ValueError): order.foods[menu_section].remove(menu_item) - # Checking the Customer Information Section - assert order.customer_information + # Checking the customer information section + if not order.customer_information: + raise ValueError("missing customer_information") adjusted_name = order.customer_information.name adjusted_address = order.customer_information.address adjusted_delivery_time = order.customer_information.delivery_time adjusted_extra_information = order.customer_information.extra_information - # First Name Check + + # Firstname check if self.order_conditions.no_firstname.get(restaurant_name): adjusted_name = adjusted_name.split()[-1] - # No Delivery Check + # No delivery check if self.order_conditions.no_delivery.get(restaurant_name): adjusted_address = "No delivery available" - # No Delivery Time Check + # No delivery time check if self.order_conditions.no_delivery_time.get(restaurant_name): adjusted_delivery_time = "" - # No Extra Information Check + # No extra information check if self.order_conditions.no_extra_information.get(restaurant_name): adjusted_extra_information = "" # Generating new CustomerInformation - adjusted_customer_information = CustomerInfo( + adjusted_customer_information = CustomerInformation( order_id=order.customer_information.order_id, name=adjusted_name, address=adjusted_address, diff --git a/src/sincere_singularities/modules/order.py b/src/sincere_singularities/modules/order.py index f375ad5..7e4fa51 100644 --- a/src/sincere_singularities/modules/order.py +++ b/src/sincere_singularities/modules/order.py @@ -2,7 +2,7 @@ from collections import defaultdict from collections.abc import Iterable from dataclasses import dataclass, field -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING import disnake @@ -15,9 +15,9 @@ from sincere_singularities.modules.restaurant import Restaurant -@dataclass(frozen=True) -class CustomerInfo: - """The Dataclass Containing Information added in the Customer Information Section.""" +@dataclass(frozen=True, slots=True) +class CustomerInformation: + """The dataclass containing information added in the customer information section.""" order_id: str name: str @@ -26,25 +26,31 @@ class CustomerInfo: extra_information: str -@dataclass(unsafe_hash=True) +@dataclass class Order: - """The Dataclass Containing Order Information.""" + """The dataclass containing order information.""" - customer_information: CustomerInfo | None = None + customer_information: CustomerInformation | None = None restaurant_name: str | None = None foods: defaultdict[str, list[str]] = field(default_factory=lambda: defaultdict(list[str])) def __post_init__(self) -> None: - # Recalculate the Order Timestamp to match Initialization - self.order_timestamp = datetime.utcnow() # Timezone doesnt matter + # Calculate the order and penalty timestamp + self.order_timestamp = datetime.now(tz=UTC) self.penalty_seconds = random.randint(4 * 60, 6 * 60) self.penalty_timestamp = self.order_timestamp + timedelta(seconds=self.penalty_seconds) -class CustomerInfoModal(disnake.ui.Modal): - """The Modal for entering Customer Information.""" +class CustomerInformationModal(disnake.ui.Modal): + """The modal for entering customer information.""" def __init__(self, order_view: "OrderView") -> None: + """ + Initialize a CustomerInfoModal instance. + + Args: + order_view (OrderView): The order view. + """ self.order_view = order_view components = [ disnake.ui.TextInput(label="Order ID", custom_id="order_id", style=TextInputStyle.short, max_length=64), @@ -63,11 +69,18 @@ def __init__(self, order_view: "OrderView") -> None: ] super().__init__(title="Customer information", components=components) - async def callback(self, inter: ModalInteraction) -> None: - """The Callback when the User has entered the Customer Information.""" + async def callback(self, interaction: ModalInteraction) -> None: + """ + The callback when the user has entered the customer information. + + This function checks if the order ID is correct (warns the user if not) and stores the provided data. + + Args: + interaction (ModalInteraction): The modal interaction. + """ # Check if wrong OrderID was entered - if not self.order_view.restaurant.order_queue.get_order_by_id(inter.text_values["order_id"]): - # Adding Error Message + if not self.order_view.restaurant.order_queue.get_order_by_id(interaction.text_values["order_id"]): + # Adding error message embed = self.order_view.embed embed.insert_field_at(index=0, name=" ", value=" ", inline=False) embed.insert_field_at( @@ -76,104 +89,158 @@ async def callback(self, inter: ModalInteraction) -> None: value="**Incorrect order ID. Try again.**", inline=False, ) - await inter.response.edit_message(view=self.order_view, embed=embed) + await interaction.response.edit_message(view=self.order_view, embed=embed) return self.order_view.order.restaurant_name = self.order_view.restaurant.name - self.order_view.order.customer_information = CustomerInfo( - order_id=inter.text_values["order_id"], - name=inter.text_values["name"], - address=inter.text_values["address"], - delivery_time=inter.text_values["time"], - extra_information=inter.text_values["extra"], + self.order_view.order.customer_information = CustomerInformation( + order_id=interaction.text_values.get("order_id", ""), + name=interaction.text_values.get("name", ""), + address=interaction.text_values.get("address", ""), + delivery_time=interaction.text_values.get("time", ""), + extra_information=interaction.text_values.get("extra", ""), ) - await inter.response.edit_message(view=self.order_view, embed=self.order_view.embed) - + await interaction.response.edit_message(view=self.order_view, embed=self.order_view.embed) -class FoodButton(disnake.ui.Button): - """A Button for adding a specific Menu Item.""" - def __init__(self, food_view: "FoodsView", order: Order, menu_item: str, food: str) -> None: - super().__init__(label=food, style=ButtonStyle.primary) - self.food_view = food_view +class MenuItemButton(disnake.ui.Button): + """A button for adding a specific menu item.""" + + def __init__(self, menu_item_view: "MenuItemView", order: Order, menu_section: str, menu_item: str) -> None: + """ + Initialize the menu item button. + + Args: + menu_item_view (MenuItemView): The menu item view. + order (Order): The order. + menu_section (str): The menu section. + menu_item (str): The menu item. + """ + super().__init__(label=menu_item, style=ButtonStyle.primary) + self.menu_item_view = menu_item_view self.order = order + self.menu_section = menu_section self.menu_item = menu_item - self.food = food - async def callback(self, inter: MessageInteraction) -> None: - """The Callback after adding a specific Menu Item.""" - self.order.foods[self.menu_item].append(self.food) - await inter.response.edit_message(view=self.food_view, embed=self.food_view.order_view.embed) + async def callback(self, interaction: MessageInteraction) -> None: + """ + The callback after adding a specific menu item. + + This function stores the clicked menu item. + Args: + interaction (MessageInteraction): The message interaction. + """ + self.order.foods[self.menu_section].append(self.menu_item) + await interaction.response.edit_message(view=self.menu_item_view, embed=self.menu_item_view.order_view.embed) -class FoodsView(disnake.ui.View): - """The View for adding a Menu Items (e.g. Main Courses).""" + +class MenuItemView(disnake.ui.View): + """The view for adding menu items from a menu section.""" def __init__( - self, restaurant: "Restaurant", order_view: "OrderView", order: Order, menu_item: str, foods: Iterable[str] + self, + restaurant: "Restaurant", + order_view: "OrderView", + order: Order, + menu_section: str, + menu_item: Iterable[str], ) -> None: + """ + Initialize the menu item view. + + Args: + restaurant (Restaurant): The restaurant. + order_view (OrderView): The order view. + order (Order): The order. + menu_section (str): The menu section. + menu_item (Iterable[str]): The menu items of the menu section. + """ super().__init__() self.restaurant = restaurant self.order_view = order_view self.order = order - self.menu_item = menu_item - for food in foods: - self.add_item(FoodButton(self, self.order, self.menu_item, food)) + self.menu_section = menu_section + for food in menu_item: + self.add_item(MenuItemButton(self, self.order, self.menu_section, food)) @disnake.ui.button(label="Back", style=disnake.ButtonStyle.secondary, row=1) - async def _food_back(self, _: disnake.ui.Button, inter: disnake.MessageInteraction) -> None: - await inter.response.edit_message(view=self.order_view, embed=self.order_view.embed) + async def _food_back(self, _: disnake.ui.Button, interaction: disnake.MessageInteraction) -> None: + await interaction.response.edit_message(view=self.order_view, embed=self.order_view.embed) -class MenuItemButton(disnake.ui.Button): - """The Button for accessing a Menu Part (e.g. Main Courses)""" +class MenuSectionButton(disnake.ui.Button): + """The button for accessing a menu section (e.g. Main Courses)""" def __init__( - self, restaurant: "Restaurant", order_view: "OrderView", order: Order, menu_item: str, btn_index: int + self, restaurant: "Restaurant", order_view: "OrderView", order: Order, menu_section: str, button_index: int ) -> None: - row_index = 0 if btn_index <= 1 else 1 - super().__init__(label=menu_item, style=ButtonStyle.primary, row=row_index, custom_id=menu_item) + """ + Initialize the menu section button. + + Args: + restaurant (Restaurant): The restaurant. + order_view (OrderView): The order view. + order (Order): The order. + menu_section (str): The menu section. + button_index (int): The button's index. + """ + row_index = 0 if button_index <= 1 else 1 + super().__init__(label=menu_section, style=ButtonStyle.primary, row=row_index, custom_id=menu_section) self.restaurant = restaurant self.order_view = order_view self.order = order - self.menu_item = menu_item + self.menu_item = menu_section + + async def callback(self, interaction: MessageInteraction) -> None: + """ + The callback to access a menu section. + + This function edits the original message to view the chosen FoodsView. - async def callback(self, inter: MessageInteraction) -> None: - """The Callback to access a Menu Part.""" - food_view = FoodsView( + Args: + interaction (MessageInteraction): The message interaction. + """ + food_view = MenuItemView( self.restaurant, self.order_view, self.order, self.menu_item, self.restaurant.restaurant_json.menu[self.menu_item], ) - await inter.response.edit_message(view=food_view, embed=self.order_view.embed) + await interaction.response.edit_message(view=food_view, embed=self.order_view.embed) class OrderView(disnake.ui.View): """The view in which the order is displayed and managed by the user.""" def __init__(self, restaurant: "Restaurant") -> None: + """ + Initialize the order view. + + Args: + restaurant (Restaurant): The restaurant. + """ super().__init__() self.restaurant = restaurant self.order = Order() for i, menu_item in enumerate(restaurant.menu): - self.add_item(MenuItemButton(restaurant, self, self.order, menu_item, i)) + self.add_item(MenuSectionButton(restaurant, self, self.order, menu_item, i)) @property def embed(self) -> disnake.Embed: - """Generating a new dynamic Embed for the Order Overview.""" + """disnake.Embed: An embed for the order overview.""" embed = disnake.Embed( title=f"{self.restaurant.icon} MENU: {self.restaurant.name} {self.restaurant.icon}", description=f"{self.restaurant.description} \n", colour=DISNAKE_COLORS.get(self.restaurant.icon, disnake.Color.random()), ) - # Adding an Empty Field for better formatting + # Adding an empty field for better formatting embed.add_field(" ", " ") - # Adding Already Added Menu Items + # Adding already added menu items for menu_name, menu_items in self.order.foods.items(): embed.add_field( - name=f"Added {menu_name} Items", + name=f"Added {menu_name} items", value="\n".join(f"- `{item_name}`: {menu_items.count(item_name)}" for item_name in set(menu_items)), inline=False, ) @@ -181,12 +248,12 @@ def embed(self) -> disnake.Embed: return embed @disnake.ui.button(label="Customer Information", style=disnake.ButtonStyle.success, row=2) - async def _customer_information(self, _: disnake.ui.Button, inter: disnake.MessageInteraction) -> None: - await inter.response.send_modal(CustomerInfoModal(self)) + async def _customer_information(self, _: disnake.ui.Button, interaction: disnake.MessageInteraction) -> None: + await interaction.response.send_modal(CustomerInformationModal(self)) @disnake.ui.button(label="Done", style=disnake.ButtonStyle.success, row=2) - async def _order_done(self, _: disnake.ui.Button, inter: disnake.MessageInteraction) -> None: - # Sending Order Placed Message and back to Start Screen + async def _order_done(self, _: disnake.ui.Button, interaction: disnake.MessageInteraction) -> None: + # Warn user if customer information is missing. if not self.order.customer_information: embed = self.embed embed.insert_field_at(index=0, name=" ", value=" ", inline=False) @@ -196,69 +263,75 @@ async def _order_done(self, _: disnake.ui.Button, inter: disnake.MessageInteract value="**Customer information missing!**", inline=False, ) - await inter.response.edit_message(embed=embed, view=self) + await interaction.response.edit_message(embed=embed, view=self) return # Getting the correct order correct_order = self.restaurant.order_queue.get_order_by_id(self.order.customer_information.order_id) - assert correct_order + if not correct_order: + raise KeyError(f"order with ID {self.order.customer_information.order_id} doesn't exist") - # Calculating Correctness and Discarding Order + # Calculating correctness and discarding order correctness = self.restaurant.check_order(self.order, correct_order) await self.restaurant.order_queue.discard_order(self.order.customer_information.order_id) points = round(correctness * 10) # 100% -> 10p - # Checking how long Order Completion took - assert correct_order.order_timestamp - time_taken = (datetime.utcnow() - correct_order.order_timestamp).total_seconds() # Timezone doesnt matter - bonus_interval = 60 # (seconds) + # Checking how long order completion took + time_taken = (datetime.now(tz=UTC) - correct_order.order_timestamp).total_seconds() + bonus_seconds = 60 completion_message = "" - if time_taken <= bonus_interval: + if time_taken <= bonus_seconds: points += 5 completion_message = "You've completed the order in under a minute and get 5 bonus points! \n" elif time_taken >= correct_order.penalty_seconds: points -= 5 completion_message = "You've took to long to complete the order and receive a 5 points penalty! \n" - add_points(inter.user.id, points) + add_points(interaction.user.id, points) - # Adding Info to embed + # Adding info to embed embed = self.restaurant.restaurants.embeds[0] embed.insert_field_at(index=0, name=" ", value=" ", inline=False) embed.insert_field_at( index=1, name=":loudspeaker: :white_check_mark: Info :white_check_mark: :loudspeaker:", value=f"**Order placed successfully! Correctness: {round(correctness, 4) * 100}%.\n {completion_message}" - f"You gained {points} points; you now have {get_points(inter.user.id)}!**", + f"You gained {points} points; you now have {get_points(interaction.user.id)}!**", inline=False, ) - await inter.response.edit_message(embed=embed, view=self.restaurant.restaurants.view) + await interaction.response.edit_message(embed=embed, view=self.restaurant.restaurants.view) @disnake.ui.button(label="Show conditions", style=disnake.ButtonStyle.secondary, row=2) - async def _show_conditions(self, _: disnake.ui.Button, inter: disnake.Message.Interaction) -> None: + async def _show_conditions(self, _: disnake.ui.Button, interaction: disnake.MessageInteraction) -> None: + # Create an embed. embed = disnake.Embed(title="Current conditions", color=disnake.Color.blue()) - condition = self.restaurant.restaurants.condition_manager.order_conditions - if menu_section := condition.out_of_stock_sections.get(self.restaurant.name): + conditions = self.restaurant.restaurants.condition_manager.order_conditions + + if menu_section := conditions.out_of_stock_sections.get(self.restaurant.name): embed.add_field( "Out of stock menu sections", f"The following menu sections are out of stock: {', '.join(menu_section)}", inline=False, ) - if sections := condition.out_of_stock_items.get(self.restaurant.name): + if sections := conditions.out_of_stock_items.get(self.restaurant.name): out_of_stock_items = ", ".join([item for menu in sections.values() for item in menu]) embed.add_field( "Out of stock menu items", f"The following menu items are out of stock: {out_of_stock_items}", inline=False, ) - if condition.no_firstname.get(self.restaurant.name): + if conditions.no_firstname.get(self.restaurant.name): embed.add_field("No firstname", "You shouldn't specify the first names of the customers.", inline=False) - if condition.no_delivery.get(self.restaurant.name): + if conditions.no_delivery.get(self.restaurant.name): embed.add_field("No delivery", "Type in `No delivery available` for the address field.", inline=False) - if condition.no_delivery_time.get(self.restaurant.name): + if conditions.no_delivery_time.get(self.restaurant.name): embed.add_field("No delivery time", "You shouldn't specify the delivery time.", inline=False) - if condition.no_extra_information.get(self.restaurant.name): + if conditions.no_extra_information.get(self.restaurant.name): embed.add_field("No extra information", "You shouldn't specify extra informations.", inline=False) + + # Check if there weren't any conditions. if not embed.fields: embed.description = "No conditions at this time." - await inter.response.send_message(ephemeral=True, embed=embed) + + # Reply with the embed. + await interaction.response.send_message(ephemeral=True, embed=embed) diff --git a/src/sincere_singularities/modules/order_queue.py b/src/sincere_singularities/modules/order_queue.py index 4238809..5e6c728 100644 --- a/src/sincere_singularities/modules/order_queue.py +++ b/src/sincere_singularities/modules/order_queue.py @@ -1,12 +1,14 @@ import asyncio import random from contextlib import suppress +from typing import Self from disnake import ( ApplicationCommandInteraction, ChannelType, HTTPException, NotFound, + TextChannel, Thread, Webhook, WebhookMessage, @@ -22,42 +24,65 @@ ( "Hello, I'd like to place an order for delivery. My name is {CUSTOMER_NAME}, and I live at {CUSTOMER_ADDRESS}." " I'd like to order {ORDER_ITEMS}. Oh, and by the way, I have a cat named Fluffy and I don't like it when" - "people ring the doorbell, so please make sure to just knock politely. Please deliver it at {DELIVERY_TIME}. " - "Thank you.", + " people ring the doorbell, so please make sure to just knock politely. Please deliver it at {DELIVERY_TIME}." + " Thank you.", "Don't ring the bell", ) ] class OrderQueue: - """The Class for managing the order queue. Orders can be spawned and deleted from here.""" + """The class for managing the order queue. Orders can be spawned and deleted from here.""" - webhook: Webhook - orders_thread: Thread + def __init__(self, interaction: ApplicationCommandInteraction, webhook: Webhook, orders_thread: Thread) -> None: + """ + Initialize the order queue. - def __init__(self, inter: ApplicationCommandInteraction) -> None: - self.user = inter.user - self.channel = inter.channel + Args: + interaction (ApplicationCommandInteraction): The application command interaction. + webhook (Webhook): The webhook. + orders_thread (Thread): The orders Discord thread. + """ + self.user = interaction.user self.orders: dict[str, tuple[Order, WebhookMessage]] = {} self.running = False self.restaurant_json = load_json("restaurants.json", RestaurantJsonType) + self.webhook = webhook + self.orders_thread = orders_thread + + @classmethod + async def new(cls, interaction: ApplicationCommandInteraction) -> Self | None: + """ + Create a new order queue. + + Args: + interaction (ApplicationCommandInteraction): The application command interaction. - async def init_orders(self) -> None: - """Start the orders queue. Spawn a new Webhook and Order Thread""" - # Creating the Order Webhook + Returns: + Self | None: The new order queue, or None if a webhook couldn't be created. + """ + if not isinstance(interaction.channel, TextChannel): + raise TypeError("interaction.channel should be TextChannel") try: - self.webhook = await self.channel.create_webhook(name="GAME NAME Order Webhook") + webhook = await interaction.channel.create_webhook(name="GAME NAME Order Webhook") except CommandInvokeError: - await self.channel.send( - "Can't start GAME NAME: Maximum Amount of Webhooks reached. Delete Webhooks or try in another channel!" + await interaction.channel.send( + "Can't start GAME NAME: maximum amount of webhooks reached. Delete webhooks or try in another channel!" ) - await self.stop_orders() - - # Creating the Orders Thread - self.orders_thread = await self.channel.create_thread( + return None + orders_thread = await interaction.channel.create_thread( name="Orders Thread", type=ChannelType.public_thread, invitable=False ) - await self.orders_thread.add_user(self.user) + await orders_thread.add_user(interaction.user) + + return cls( + interaction=interaction, + webhook=webhook, + orders_thread=orders_thread, + ) + + async def start_orders(self) -> None: + """Start the orders queue. Spawns three orders.""" self.running = True # Spawn 3 Orders at the start, which get refreshed after one order is done @@ -65,7 +90,7 @@ async def init_orders(self) -> None: await self.spawn_order() async def spawn_order(self) -> None: - """Spawning a new randomly genrated Order""" + """Spawning a new randomly generated order.""" if not self.running: return @@ -80,42 +105,44 @@ async def spawn_order(self) -> None: restaurant.order_amount / order_amounts_sum for restaurant in restaurants ] - # Getting a random restaurant wheighed by their relative order amounts + # Getting a random restaurant weighed by their relative order amounts random_restaurant = random.choices(population=restaurants, weights=relative_order_amounts)[0] print(random_restaurant) - # TODO: Implement Cluckers Algo + # TODO: Implement Clucker's algo - async def create_order(self, customer_name: str, order_message: str, order_result: Order) -> None: + async def create_order(self, order_message: str, order_result: Order) -> None: """ - Create a new Order, sends a message to the Discord and stores the result to check. + Create a new order, sends a message, and stores the result to check. Args: - customer_name: The full name of the customer. - order_message: The message to send to the Discord channel. - order_result: The correct result the Order should give + order_message (str): The message to send to the Discord thread. + order_result (Order): The correct result the Order should give """ - discord_tz = f"" - order_message += f" The order should be completed within {discord_tz} seconds or you will get a penalty!" - order_message = await self.webhook.send( + if not order_result.customer_information: + raise ValueError("missing customer_information") + + discord_timestamp = f"" + order_message += ( + f" The order should be completed within {discord_timestamp} seconds or you will get a penalty!" + ) + discord_message = await self.webhook.send( content=order_message, - username=customer_name, + username="GAME NAME", # game would be too easy if the customer's name was here avatar_url=generate_random_avatar_url(), wait=True, thread=self.orders_thread, ) - - assert order_result.customer_information - self.orders[order_result.customer_information.order_id] = (order_result, order_message) + self.orders[order_result.customer_information.order_id] = (order_result, discord_message) def get_order_by_id(self, order_id: str) -> Order | None: """ - Get a specific Order by ID. + Get a specific order by its ID. Args: - order_id: The ID of the Order to retrieve. + order_id (str): The ID of the order to retrieve. Returns: - The Correct Order, the WebhookMessage (to delete after the order is done) + Order | None: The order with that ID or None. """ if order := self.orders.get(order_id): return order[0] @@ -123,25 +150,23 @@ def get_order_by_id(self, order_id: str) -> Order | None: async def discard_order(self, order_id: str) -> None: """ - Discard a specific Order by ID after its completed. + Discard a specific order by its ID after it's completed. Args: - order_id: ID of the Order to discard. + order_id (str): The ID of the order to discard. """ - with suppress(KeyError): - del self.orders[order_id] + del self.orders[order_id] - # Wait 10-20 Seconds as an order Cooldown - order_timeout = random.randint(10, 20) - await asyncio.sleep(order_timeout) + # Wait 10-20 Seconds as an order cooldown + await asyncio.sleep(random.randint(10, 20)) await self.spawn_order() async def stop_orders(self) -> None: - """Stop All Orders (when stopping the game).""" + """Stop all orders (when stopping the game).""" self.running = False with suppress(HTTPException, NotFound): - # Deleting Webhook + # Deleting webhook await self.webhook.delete() with suppress(HTTPException, NotFound): - # Deleting Orders Thread + # Deleting orders thread await self.orders_thread.delete() diff --git a/src/sincere_singularities/modules/points.py b/src/sincere_singularities/modules/points.py index 8130abf..ab2b439 100644 --- a/src/sincere_singularities/modules/points.py +++ b/src/sincere_singularities/modules/points.py @@ -23,7 +23,7 @@ def get_restaurant_by_name(name: str) -> RestaurantJson: raise ValueError(f"Restaurant named {name!r} doesn't exist") -# vvv temporary until #6 gets merges vvv +# vvv temporary until #6 gets merged vvv class TemporaryDatabaseEntry(TypedDict): diff --git a/src/sincere_singularities/modules/restaurant.py b/src/sincere_singularities/modules/restaurant.py index c7b0339..239e8c7 100644 --- a/src/sincere_singularities/modules/restaurant.py +++ b/src/sincere_singularities/modules/restaurant.py @@ -1,4 +1,5 @@ from collections import Counter +from collections.abc import Iterable from typing import TYPE_CHECKING from disnake import MessageInteraction @@ -11,20 +12,20 @@ from sincere_singularities.modules.restaurants_view import Restaurants -def count_differences(list0: list[str], list1: list[str]) -> int: +def count_differences(first_iterable: Iterable[object], second_iterable: Iterable[object]) -> int: """ - Count the Differences between two lists, indexes independent. + Count the differences between two iterables, indexes independent. Args: - list0: First List to check. - list1: Second List to check. + first_iterable (Iterable[object]): First iterable to check. + second_iterable (Iterable[object]): Second iterable to check. Returns: - The Amount of Differences. + int: The amount of differences. """ - # Initialize Counters on the Lists - counter0 = Counter(list0) - counter1 = Counter(list1) + # Initialize counters + counter0 = Counter(first_iterable) + counter1 = Counter(second_iterable) # Calculate the total differences return sum((counter0 - counter1).values()) + sum((counter1 - counter0).values()) @@ -34,6 +35,13 @@ class Restaurant: """Represents a single restaurant.""" def __init__(self, restaurants: "Restaurants", restaurant_json: RestaurantJson) -> None: + """ + Initialize the restaurant. + + Args: + restaurants (Restaurants): The restaurants. + restaurant_json (RestaurantJson): The restaurants JSON. + """ self.restaurants = restaurants self.restaurant_json = restaurant_json @@ -43,65 +51,66 @@ def __init__(self, restaurants: "Restaurants", restaurant_json: RestaurantJson) self.points = restaurant_json.points self.menu = restaurant_json.menu - # Order Related self.order_queue: OrderQueue = restaurants.order_queue - async def enter_menu(self, inter: MessageInteraction) -> None: + async def enter_menu(self, interaction: MessageInteraction) -> None: """ - Function Called initially when the user enters the restaurant + Function called initially when the user enters the restaurant. Args: - inter: The Disnake MessageInteraction object. + interaction (MessageInteraction): The Disnake MessageInteraction object. """ view = OrderView(self) - await inter.response.edit_message(embed=view.embed, view=view) + await interaction.response.edit_message(embed=view.embed, view=view) def check_order(self, order: Order, correct_order: Order) -> float: """ Checking if the order was correctly placed by the user. Args: - order: The Order to check. - correct_order: The Correct Order to check against. + order (Order): The order to check. + correct_order (Order): The correct order to check against. Returns: - How correct the order was placed in percentage (as a float) + float: How correct the order was placed in percentage """ - # Adjust Order to Conditions + # Adjust order to conditions correct_order = self.restaurants.condition_manager.adjust_order_to_conditions(correct_order) score = 1.0 - # The effect on the Score each wrong answer should have - # (Length of Menu Items + Customer Information Items + 1 for the restaurant) + # The effect on the score each wrong answer should have + # (Length of menu items + customer information items + 1 for the restaurant) score_percentile = 1 / (len(correct_order.foods) + 4 + 1) - # Subtracting Sentiment Analysis Scores of the Customer Information + # Subtracting sentiment analysis scores of the customer information # This is achieved using a linear interpolation, meaning if the check gives 1.0, 0.0 will be subtracted from # the score, but when the check gives 0.0, score_percentile will be subtracted correct_customer_information = correct_order.customer_information - assert correct_customer_information + if not correct_customer_information: + raise ValueError("missing correct_order.customer_information") customer_information = order.customer_information - assert customer_information + if not customer_information: + raise ValueError("missing order.customer_information") # Restaurant if correct_order.restaurant_name != order.restaurant_name: score -= score_percentile - # Customer Name + # Customer name name_check = check_pattern_similarity(correct_customer_information.address, customer_information.address) score -= score_percentile + (-score_percentile * name_check) - # Customer Address + # Customer address address_check = check_pattern_similarity(correct_customer_information.address, customer_information.address) score -= score_percentile + (-score_percentile * address_check) - # Delivery Time + # Delivery time delivery_time_check = compare_sentences( correct_customer_information.delivery_time, customer_information.delivery_time ) score -= score_percentile + (-score_percentile * delivery_time_check) - # Extra Information + # Extra information extra_info_check = compare_sentences( correct_customer_information.extra_information, customer_information.extra_information ) @@ -111,7 +120,7 @@ def check_order(self, order: Order, correct_order: Order) -> float: # Getting every order item correct_order_items = [item for menu_items in correct_order.foods.values() for item in menu_items] all_order_items = [item for menu_items in order.foods.values() for item in menu_items] - # Finding Differences between Orders and subtracting from Score + # Finding differences between orders and subtracting from score order_differences = count_differences(correct_order_items, all_order_items) score -= score_percentile * order_differences diff --git a/src/sincere_singularities/modules/restaurants_view.py b/src/sincere_singularities/modules/restaurants_view.py index 0f43ad6..a11a76a 100644 --- a/src/sincere_singularities/modules/restaurants_view.py +++ b/src/sincere_singularities/modules/restaurants_view.py @@ -13,32 +13,49 @@ class RestaurantPurchaseView(disnake.ui.View): - """View subclass for buying a restaurant""" + """View subclass for buying a restaurant.""" def __init__(self, user_id: int, restaurant: Restaurant, parent: "RestaurantsView") -> None: + """ + Initialize a the restaurant purchase view. + + Args: + user_id (int): The user's ID. + restaurant (Restaurant): The restaurant. + parent (RestaurantsView): The restaurants view. + """ super().__init__(timeout=None) self.user_id = user_id self.restaurant = restaurant self.parent = parent + # Disable the buy button if the used doesn't have enough points. if get_points(user_id) < restaurant.points: self._buy.disabled = True @disnake.ui.button(label="Buy", style=disnake.ButtonStyle.success) - async def _buy(self, _: disnake.ui.Button, inter: disnake.MessageInteraction) -> None: + async def _buy(self, _: disnake.ui.Button, interaction: disnake.MessageInteraction) -> None: buy_restaurant(self.user_id, self.restaurant.name) - await inter.response.edit_message(view=self.parent, embed=self.parent.embeds[self.parent.index]) + await interaction.response.edit_message(view=self.parent, embed=self.parent.embeds[self.parent.index]) @disnake.ui.button(label="Cancel", style=disnake.ButtonStyle.secondary) - async def _cancel(self, _: disnake.ui.Button, inter: disnake.MessageInteraction) -> None: - await inter.response.edit_message(view=self.parent, embed=self.parent.embeds[self.parent.index]) + async def _cancel(self, _: disnake.ui.Button, interaction: disnake.MessageInteraction) -> None: + await interaction.response.edit_message(view=self.parent, embed=self.parent.embeds[self.parent.index]) class RestaurantsView(disnake.ui.View): - """View Subclass for Choosing the Restaurant""" + """View subclass for choosing the restaurant.""" + + def __init__(self, restaurants: "Restaurants", embeds: list[disnake.Embed], index: int = 0) -> None: + """ + Initialize the restaurants view. - def __init__(self, ctx: "Restaurants", embeds: list[disnake.Embed], index: int = 0) -> None: + Args: + restaurants (Restaurants): The restaurants. + embeds (list[disnake.Embed]): The restaurant embeds. + index (int, optional): The index to start from. Defaults to 0. + """ super().__init__(timeout=None) - self.ctx = ctx + self.restaurants = restaurants self.embeds = embeds self.index = index @@ -49,24 +66,26 @@ def __init__(self, ctx: "Restaurants", embeds: list[disnake.Embed], index: int = self._update_state() def _update_state(self) -> None: + # Disable previous/next button for first/last embeds self._prev_page.disabled = self.index == 0 self._next_page.disabled = self.index == len(self.embeds) - 1 @disnake.ui.button(emoji="◀", style=disnake.ButtonStyle.secondary, row=0) - async def _prev_page(self, _: disnake.ui.Button, inter: disnake.MessageInteraction) -> None: + async def _prev_page(self, _: disnake.ui.Button, interaction: disnake.MessageInteraction) -> None: self.index -= 1 self._update_state() - await inter.response.edit_message(embed=self.embeds[self.index], view=self) + await interaction.response.edit_message(embed=self.embeds[self.index], view=self) @disnake.ui.button(label="Enter Restaurant", style=disnake.ButtonStyle.success, row=0) - async def _enter_restaurant(self, _: disnake.ui.Button, inter: disnake.MessageInteraction) -> None: - # Find Restaurant based on current index - restaurant = self.ctx.restaurants[self.index] - if not has_restaurant(inter.user.id, restaurant.name): - user_points = get_points(inter.user.id) - await inter.response.edit_message( - view=RestaurantPurchaseView(inter.user.id, restaurant, self), + async def _enter_restaurant(self, _: disnake.ui.Button, interaction: disnake.MessageInteraction) -> None: + # Find restaurant based on current index + restaurant = self.restaurants.restaurants[self.index] + # Show purchase view if the user doesn't own the restaurant + if not has_restaurant(interaction.user.id, restaurant.name): + user_points = get_points(interaction.user.id) + await interaction.response.edit_message( + view=RestaurantPurchaseView(interaction.user.id, restaurant, self), embed=disnake.Embed( title="You do not own this restaurant.", description=f"It costs {restaurant.points} points.\nYou have {user_points}.\nAfter buying it," @@ -75,75 +94,76 @@ async def _enter_restaurant(self, _: disnake.ui.Button, inter: disnake.MessageIn ), ) return - # Stopping view + # Stopping this view self.stop() - await restaurant.enter_menu(inter) + await restaurant.enter_menu(interaction) @disnake.ui.button(emoji="▶", style=disnake.ButtonStyle.secondary, row=0) - async def _next_page(self, _: disnake.ui.Button, inter: disnake.MessageInteraction) -> None: + async def _next_page(self, _: disnake.ui.Button, interaction: disnake.MessageInteraction) -> None: self.index += 1 self._update_state() - await inter.response.edit_message(embed=self.embeds[self.index], view=self) + await interaction.response.edit_message(embed=self.embeds[self.index], view=self) @disnake.ui.button(label="Pause Orders", style=disnake.ButtonStyle.secondary, row=1) - async def _pause_orders(self, *_: disnake.ui.Button | disnake.MessageInteraction) -> None: - # Placebo Button. Doesnt do anything but looks nice (to give the user feeling of control.) - # This button doesnt do anything because the game ensure the user has 3 orders at all times, so you wont get - # more than 3 orders anyway, and they dont run out - return + async def _pause_orders(self, _: disnake.ui.Button, interaction: disnake.MessageInteraction) -> None: + # Placebo Button. Doesn't do anything but looks nice (to give the user feeling of control.) + # This button doesn't do anything because the game ensure the user has 3 orders at all times, so you won't get + # more than 3 orders anyway, and they don't run out. + await interaction.response.defer() @disnake.ui.button(label="Stop the Game", style=disnake.ButtonStyle.danger, row=1) async def _stop_game(self, *_: disnake.ui.Button | disnake.MessageInteraction) -> None: # TODO: Savestates? # TODO: fix awful typing when implemented - await self.ctx.inter.delete_original_message() - await self.ctx.order_queue.stop_orders() + await self.restaurants.interaction.delete_original_message() + await self.restaurants.order_queue.stop_orders() class Restaurants: - """Class to Manage the Restaurants & UI""" - - condition_manager: "ConditionManager" + """Class to manage the restaurants and the UI.""" + + def __init__( + self, + interaction: disnake.ApplicationCommandInteraction, + order_queue: OrderQueue, + condition_manager: "ConditionManager", + ) -> None: + """ + Initialize the restaurants. - def __init__(self, inter: disnake.ApplicationCommandInteraction, order_queue: OrderQueue) -> None: - self.inter = inter - self.order_queue = order_queue - # Loading Restaurants + Args: + interaction (disnake.ApplicationCommandInteraction): The Disnake application command interaction. + order_queue (OrderQueue): The order queue. + condition_manager (ConditionManager): The condition manager. + """ + self.interaction = interaction + self.order_queue: OrderQueue = order_queue + self.condition_manager = condition_manager + # Loading restaurants self.restaurants_json = order_queue.restaurant_json @property def view(self) -> RestaurantsView: - """ - Getting the View Object for the Restaurant. Method to reload the View everytime. - - Returns: - RestaurantsView: The View Object - """ + """RestaurantsView: The view object for the restaurants.""" return RestaurantsView(self, self.embeds) @property def embeds(self) -> list[disnake.Embed]: - """ - Getting the Embeds of each Restaurant (On the Restaurant Selection Screen). - - Returns: - List of Disnake Embeds - - """ - # Generate Embeds from Restaurants - embeds = [] + """list[disnake.Embed]: The embeds of the restaurants (on the restaurant selection screen).""" + # Generate embeds from restaurants + embeds: list[disnake.Embed] = [] for restaurant in self.restaurants_json: embed = disnake.Embed( title=f"{restaurant.icon} {restaurant.name} {restaurant.icon}", description=f"{restaurant.description} \n**Required points**: {restaurant.points}" - f" (you have {get_points(self.inter.user.id)})", + f" (you have {get_points(self.interaction.user.id)})", colour=DISNAKE_COLORS.get(restaurant.icon, disnake.Color.random()), ) - # Adding an Empty Field for better formatting + # Adding an empty field for better formatting embed.add_field(" ", " ") - # Adding Examples from the Menu + # Adding examples from the Menu embed.add_field( name="Example Starter", value=f"`{random.choice(restaurant.menu['Starters'])}`", @@ -171,15 +191,10 @@ def embeds(self) -> list[disnake.Embed]: @property def restaurants(self) -> list[Restaurant]: - """ - Getting the Restaurants List, each Restaurant is initialized via its JSON. - - Returns: List of Restaurant Classes - - """ + """list[Restaurant]: The restaurants list, each restaurant is initialized via its JSON.""" # Creating Restaurant Objects Based on the Data return [ Restaurant(self, restaurant) for restaurant in self.restaurants_json - if has_restaurant(self.inter.user.id, restaurant.name) + if has_restaurant(self.interaction.user.id, restaurant.name) ] diff --git a/src/sincere_singularities/utils.py b/src/sincere_singularities/utils.py index 8b80391..7a7d0ac 100644 --- a/src/sincere_singularities/utils.py +++ b/src/sincere_singularities/utils.py @@ -3,14 +3,14 @@ import random from dataclasses import dataclass from pathlib import Path -from typing import TypeAlias, TypeVar, get_args, get_origin +from typing import TypeAlias, TypeVar, cast, get_args, get_origin import dacite import disnake import torch from sentence_transformers import SentenceTransformer, util -CURRENT_DIR = Path(__file__).parent.absolute() +CURRENT_DIR = Path(__file__).parent.resolve() DISNAKE_COLORS = { ":pizza:": disnake.Color.from_rgb(229, 97, 38), ":sushi:": disnake.Color.from_rgb(255, 153, 153), @@ -24,7 +24,7 @@ @dataclass(unsafe_hash=True) class RestaurantJson: - """Represents a JSON-like object representing a Restaurant.""" + """Represents a JSON-like object representing a restaurant.""" name: str icon: str @@ -40,30 +40,29 @@ class RestaurantJson: def load_json(filename: str, json_type: type[T]) -> T: """ - Helper for Loading a Json File + Helper for loading a JSON file. Args: - filename: Filename, e.g. myfile.json - json_type: Json Type - - Returns: JsonType + filename (str): The file's name (e.g. myfile.json). + json_type (type[T]): The type to load the file into. + Returns: + T: The data of the file. """ - # Opening the FilePath which is found under ./data/... + # Opening the filepath which is found under ./data/... json_filepath = CURRENT_DIR / "data" / filename with json_filepath.open(mode="r", encoding="utf-8") as f: loaded_json = json.load(f) if isinstance(loaded_json, list) and get_origin(json_type) is list: - # This gets the Class Type of the first element of json_type + # This gets the class type of the first element of json_type # Note: We can assume the json_type list only has one element - obj_type = get_args(json_type)[0] + object_type = get_args(json_type)[0] - # Applying the Dataclass to every Object in the List. - typed_objs: T = [dacite.from_dict(obj_type, obj) for obj in loaded_json] # type: ignore[assignment] - return typed_objs + # Applying the dataclass to every object in the list. + return cast(T, [dacite.from_dict(object_type, obj) for obj in loaded_json]) - # Applying the Dataclass to the loaded_json + # Applying the dataclass to the loaded_json typed_json: T = dacite.from_dict(json_type, loaded_json) return typed_json From c50d5e10194e3315ac935624babad5b07fe3db8c Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Sat, 27 Jul 2024 18:34:07 +0200 Subject: [PATCH 18/43] Various Quality of Life Improvements (#17) * Various Quality of Life Improvements - Added Order Generator - Improved Code Flow - Added New Features Co-Authored-By: BSP <113061114+clucker-m8@users.noreply.github.com> Co-Authored-By: koviubi56 * Fixed some Linting Errors * Fix Linting * Add docstrings * Fix Formatting * points.py default points to 0 Co-authored-by: koviubi56 * Fix Changes --------- Co-authored-by: BSP <113061114+clucker-m8@users.noreply.github.com> Co-authored-by: koviubi56 --- pyproject.toml | 4 + requirements.txt | 1 + src/sincere_singularities/bot.py | 41 +-- src/sincere_singularities/data/__init__.py | 0 .../data/extra_informations.py | 73 ++++ .../data/intros_outros.py | 69 ++++ src/sincere_singularities/data/noise.py | 344 ++++++++++++++++++ .../modules/conditions.py | 11 +- src/sincere_singularities/modules/order.py | 16 +- .../modules/order_generator.py | 287 +++++++++++++++ .../modules/order_queue.py | 48 ++- src/sincere_singularities/modules/points.py | 10 +- .../modules/restaurant.py | 4 +- .../modules/restaurants_view.py | 54 ++- src/sincere_singularities/utils.py | 11 +- 15 files changed, 885 insertions(+), 88 deletions(-) create mode 100644 src/sincere_singularities/data/__init__.py create mode 100644 src/sincere_singularities/data/extra_informations.py create mode 100644 src/sincere_singularities/data/intros_outros.py create mode 100644 src/sincere_singularities/data/noise.py create mode 100644 src/sincere_singularities/modules/order_generator.py diff --git a/pyproject.toml b/pyproject.toml index 94c241a..f1dad0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,6 +101,10 @@ ignore = [ "S101", # Function Arguments "PLR0913", + # Magic Values + "PLR2004", + # Positional Boolean + "FBT001" ] [tool.ruff.lint.pydocstyle] diff --git a/requirements.txt b/requirements.txt index a736aec..8e3e303 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ torch~=2.3.1 sentence-transformers~=3.0.1 transformers~=4.43.2 numpy<2 +faker~=26.0.0 diff --git a/src/sincere_singularities/bot.py b/src/sincere_singularities/bot.py index 9416c61..1405b21 100644 --- a/src/sincere_singularities/bot.py +++ b/src/sincere_singularities/bot.py @@ -16,7 +16,7 @@ background_tasks: set[asyncio.Task[None]] = set() -@bot.slash_command(name="clear_webhooks") +@bot.slash_command(name="clear_webhooks", description="Clears the webhooks in a channel.") async def clear_webhooks(interaction: ApplicationCommandInteraction) -> None: """ Clears the webhooks in a channel. @@ -38,14 +38,14 @@ async def clear_webhooks(interaction: ApplicationCommandInteraction) -> None: await interaction.response.send_message("You don't have the permissions to manage webhooks!", ephemeral=True) return + await interaction.response.send_message("Webhooks cleared!", ephemeral=True) + webhooks = await interaction.channel.webhooks() for webhook in webhooks: await webhook.delete() - await interaction.response.send_message("Webhooks cleared!", ephemeral=True) - -@bot.slash_command(name="clear_threads") +@bot.slash_command(name="clear_threads", description="Clears the threads in a channel.") async def clear_threads(interaction: ApplicationCommandInteraction) -> None: """ Clears the threads in a channel. @@ -67,13 +67,13 @@ async def clear_threads(interaction: ApplicationCommandInteraction) -> None: await interaction.response.send_message("You don't have the permissions to manage threads!", ephemeral=True) return + await interaction.response.send_message("Threads cleared!", ephemeral=True) + for thread in interaction.channel.threads: await thread.delete() - await interaction.response.send_message("Threads cleared!", ephemeral=True) - -@bot.slash_command(name="start_game") +@bot.slash_command(name="start_game", description="Starts the game.") async def start_game(interaction: ApplicationCommandInteraction) -> None: """ Start the game. @@ -101,36 +101,15 @@ async def start_game(interaction: ApplicationCommandInteraction) -> None: await interaction.response.send_message(embed=restaurants.embeds[0], view=restaurants.view, ephemeral=True) # Spawning orders - await order_queue.start_orders() + task = asyncio.create_task(order_queue.start_orders()) + background_tasks.add(task) + task.add_done_callback(background_tasks.discard) # Spawning conditions task = asyncio.create_task(condition_manager.spawn_conditions()) background_tasks.add(task) task.add_done_callback(background_tasks.discard) - # Creating temporary example order - from sincere_singularities.modules.order import CustomerInformation, Order - - customer_info = CustomerInformation( - order_id="Test123", - name="Customer Name", - address="Customer Address", - delivery_time="9 o'clock.", - extra_information="Dont ring the bell.", - ) - example_order_text = str( - "OrderID: Test123 \n" - "Hello, my name is Customer Name. I would like to have 2 Pizza Starter0 and a " - "Main Course0 delivered to my house Customer Address at 9 o'clock. " - "Please dont ring the bell." - ) - example_order = Order(customer_information=customer_info, restaurant_name="Pizzaria") - example_order.foods["Starters"].append("Garlic Knots") - example_order.foods["Starters"].append("Garlic Knots") - example_order.foods["Main Courses"].append("Veggie Pizza") - - await order_queue.create_order(example_order_text, example_order) - @bot.event async def on_ready() -> None: diff --git a/src/sincere_singularities/data/__init__.py b/src/sincere_singularities/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sincere_singularities/data/extra_informations.py b/src/sincere_singularities/data/extra_informations.py new file mode 100644 index 0000000..40c8f3f --- /dev/null +++ b/src/sincere_singularities/data/extra_informations.py @@ -0,0 +1,73 @@ +EXTRA_INFORMATIONS_WITH_ADDITIONS = { + "Please don't ring the bell": "Oh, and tell the delivery guy to not ring the bell", + "Make sure the food isn't cold": "It's crucial the food arrives hot, please.", + "Add some spice to the main dishes": "Could you kick up the heat a bit?", + "No onions, please": "I really can't stand onions, thanks.", + "Extra napkins, please": "And please toss in a few extra napkins.", + "Include utensils": "Don't forget the utensils, please.", + "Extra ketchup packets": "I could use a few more ketchup packets.", + "Leave the food at the door": "Just leave it at the door, no need to knock.", + "Add extra cheese": "I'd love some extra cheese on that.", + "Gluten-free option if available": "If there's a gluten-free option, I'll take it.", + "Please deliver exactly on time": "Timeliness is key; please be punctual.", + "Include straws": "Straws would be great, thanks.", + "Make it extra crispy": "Make it as crispy as you can, please.", + "Add extra sauce": "More sauce, please, I love it saucy.", + "Separate the sauces, please": "Could you keep the sauces separate?", + "Add a side of avocado": "A side of avocado would be perfect.", + "More soy sauce packets": "I'd like some extra soy sauce packets.", + "No nuts, please": "Please ensure there are no nuts, I'm allergic.", + "Extra lime wedges": "A few extra lime wedges would be nice.", + "No cilantro, please": "Hold the cilantro, please.", + "Extra spicy, please": "Make it extra spicy for me.", + "Make it mild": "Could you make it mild? Thanks.", + "Add extra jalapenos": "Throw in some extra jalapenos.", + "No dairy, please": "Please make sure there's no dairy.", + "More lemon slices": "I'd appreciate more lemon slices.", + "Make it vegetarian": "I'd like it to be vegetarian, please.", + "Include hot sauce packets": "Hot sauce packets would be great.", + "Add a side of ranch": "A side of ranch, please.", + "More napkins, please": "I need more napkins, if possible.", + "Use olive oil instead of butter": "Olive oil instead of butter, please.", + "Extra ice, please": "Could you add extra ice?", + "No green peppers": "Please, no green peppers.", + "Include a side of gravy": "I'd like a side of gravy.", + "More hot mustard packets": "More hot mustard packets, please.", + "Add extra olives": "I'd love extra olives, thanks.", + "Add a side of honey": "A side of honey would be nice.", + "No garlic, please": "Make sure there's no garlic, please.", + "Extra fresh herbs": "I'd love some extra fresh herbs.", + "Include chopsticks": "Could you include chopsticks, please?", + "Add extra mint leaves": "Extra mint leaves would be appreciated.", + "No pepper on the food": "No pepper on my food, please.", + "Extra tartar sauce": "More tartar sauce, please.", + "Make it extra saucy": "Could you make it extra saucy?", + "Add a side of coleslaw": "A side of coleslaw would be great.", + "Include extra bread rolls": "Extra bread rolls would be awesome.", + "No eggs, please": "Please ensure there are no eggs.", + "Extra dill pickles": "I love dill pickles, so extra, please.", + "Make it low-sodium": "Could you make it low-sodium?", + "Add extra croutons": "Extra croutons, please.", + "More barbecue sauce": "I'd like more barbecue sauce.", + "Include a side of fruit": "A side of fruit would be nice.", + "Make it light on the dressing": "Go easy on the dressing, please.", + "No mushrooms, please": "Please, no mushrooms.", + "Extra whipped cream": "I'd love extra whipped cream.", + "Add a side of marinara sauce": "A side of marinara sauce would be perfect.", + "More green onions": "Could you add more green onions?", + "Include a side of sour cream": "A side of sour cream, please.", + "Make it kid-friendly": "Make sure it's kid-friendly.", + "Extra black pepper": "I'd like extra black pepper.", + "Add a side of steamed vegetables": "A side of steamed vegetables would be great.", + "Extra cranberry sauce": "I love cranberry sauce, so extra, please.", + "Add a side of mac and cheese": "A side of mac and cheese, please.", + "Include a side of rice": "Could you include a side of rice?", + "Extra caramel sauce": "More caramel sauce, please.", + "Add a side of hummus": "A side of hummus would be nice.", + "More pickled vegetables": "I'd like more pickled vegetables.", + "Include a side of salsa": "Salsa on the side would be great.", + "Make it with extra love": "Please make it with extra love!", + "No sesame oil": "Please, no sesame oil.", + "Extra lemon zest": "Extra lemon zest would be wonderful.", + "Add a side of tzatziki": "A side of tzatziki, please.", +} diff --git a/src/sincere_singularities/data/intros_outros.py b/src/sincere_singularities/data/intros_outros.py new file mode 100644 index 0000000..c2b0808 --- /dev/null +++ b/src/sincere_singularities/data/intros_outros.py @@ -0,0 +1,69 @@ +INTROS = { + "intros_with_name": [ + "Hello, here!", + "Hi there, speaking!", + "Hey there, here!", + "Howdy, speaking!", + "What's up, here!", + "What's poppin', speaking!", + "Hiya, here!", + "Good day, speaking!", + "Yo, here!", + "Hey you, speaking!", + "Hi, here!", + "Greetings, speaking!", + "Hello there, here!", + "Hiya, speaking!", + ], + "intros_without_name": [ + "Hello!", + "Hi there!", + "Hey there!", + "Howdy!", + "What's up?", + "What's poppin'!", + "Hiya!", + "Good day!", + "Yo!", + "Hey you!", + "Hi!", + "Greetings!", + "Hello there!", + "Hiya!", + ], +} + +OUTROS = { + "outros_with_name": [ + "Goodbye, !", + "See you later, !", + "Take care, !", + "Farewell, !", + "Catch you later, !", + "Later, !", + "Bye for now, !", + "Until next time, !", + "Adios, !", + "So long, !", + "Peace out, !", + "Cheers, !", + "Good night, !", + "Bye-bye, !", + ], + "outros_without_name": [ + "Goodbye!", + "See you later!", + "Take care!", + "Farewell!", + "Catch you later!", + "Later!", + "Bye for now!", + "Until next time!", + "Adios!", + "So long!", + "Peace out!", + "Cheers!", + "Good night!", + "Bye-bye!", + ], +} diff --git a/src/sincere_singularities/data/noise.py b/src/sincere_singularities/data/noise.py new file mode 100644 index 0000000..407aaec --- /dev/null +++ b/src/sincere_singularities/data/noise.py @@ -0,0 +1,344 @@ +from dataclasses import dataclass + + +@dataclass +class EmbeddableNoiseFoods: + """EmbeddableNoiseFoods""" + + starters: list[str] + main: list[str] + desserts: list[str] + drinks: list[str] + + +@dataclass +class EmbeddableNoise: + """EmbeddableNoise""" + + addresses: list[str] + foods: EmbeddableNoiseFoods + times: list[str] + restaurants: list[str] + + +@dataclass +class NoiseData: + """NoiseData""" + + noise: list[str] + relevant_noise: list[str] + embeddable_noise: EmbeddableNoise + + +NOISE = NoiseData( + noise=[ + "It's such a sunny day today, perfect for a walk in the park.", + "Yesterday was unbelievable with how much it rained, flooding the streets.", + "The sky looks so clear and blue, not a cloud in sight.", + "This morning, I had a great time at the park watching the ducks.", + "My cat knocked over my plant again, creating quite the mess.", + "Finally finished reading that book I've been working on for weeks.", + "Dinner last night was fantastic, especially the dessert.", + "I can't wait for the weekend to relax and maybe go hiking.", + "Traffic was terrible this morning, making me late for work.", + "Planning to bake cookies later with a new recipe I found.", + "Have you seen the latest movie that's causing such a buzz?", + "My dog loves playing fetch in the yard, never gets tired.", + "Groceries are on my list after work, running low on essentials.", + "The sunset yesterday was absolutely beautiful, with vibrant colors.", + "Cleaned my entire house today, feels so refreshing now.", + "Thinking about redecorating my living room, maybe new curtains.", + "The flowers in my garden are blooming nicely, adding color to the yard.", + "Spent the afternoon organizing my closet, found some old treasures.", + "Tried a new recipe for lunch today, it was surprisingly good.", + "Can't find my keys anywhere, looked in all the usual spots.", + "Neighbors are having a barbecue this weekend, invited the whole block.", + "Need to take my car for a service, it's been making weird noises.", + "Power went out for a couple of hours, had to use candles.", + "Learning to play the guitar, slowly but surely improving.", + "Had a lovely walk by the beach, the sound of waves was soothing.", + "Friend is coming over for coffee tomorrow, can't wait to catch up.", + "Watering the plants before I leave, they look a bit dry.", + "The birds are singing so loudly today, feels like a symphony.", + "Saw a beautiful rainbow after the rain, it was magical.", + "Need to finish my project by the end of the week, deadlines are tight.", + "The coffee shop on the corner makes the best lattes, a must-try.", + "Love the smell of fresh laundry, so clean and crisp.", + "Had the most delicious breakfast this morning, pancakes and syrup.", + "Replace the light bulb in the kitchen, it's been flickering.", + "Found a great deal on a new laptop, couldn't resist buying it.", + "The air feels so fresh after the rain, perfect for a run.", + "Planning a surprise party for my friend, hope she loves it.", + "Just bought a new set of paints, excited to start a new project.", + "Weather forecast says it will be sunny all week, time for outdoor fun.", + "Love listening to music while I cook, makes it more enjoyable.", + "Can't believe it's already July, time flies so fast.", + "My plants are growing so fast, need to repot some of them.", + "Book a dentist appointment, it's been a while since my last check-up.", + "Spent the day hiking in the mountains, the views were breathtaking.", + "Tried yoga for the first time today, felt incredibly relaxing.", + "The stars are so bright tonight, perfect for stargazing.", + "Finally organized my desk, now it looks neat and tidy.", + "Planning to visit my grandparents this weekend, always a pleasure.", + "Love the sound of rain on the roof, so calming.", + "Need to buy a new phone charger, mine is fraying.", + "Had a picnic in the park today, the weather was perfect.", + "The autumn leaves are so beautiful, love the vibrant colors.", + "Vacuum the living room, it's starting to look a bit dusty.", + "Thinking about getting a new pet, maybe a rabbit.", + "Found a new favorite TV show, binge-watched the first season.", + "The sunrise was stunning this morning, worth waking up early for.", + "Do the dishes before bed, don't want a messy kitchen in the morning.", + "Spent the evening reading a great book, couldn't put it down.", + "Neighborhood kids are playing outside, their laughter is contagious.", + "Need to clean out the garage, it's getting cluttered.", + "Love the feeling of fresh sheets on the bed, so comfortable.", + "Went for a bike ride along the river, the scenery was beautiful.", + "Can't wait to try the new restaurant in town, heard great reviews.", + "Bought a new rug for the living room, adds a nice touch.", + "Looking forward to the holiday season, always so festive.", + "The flowers smell amazing in the garden, such a pleasant aroma.", + "Had a relaxing bath after a long day, felt rejuvenating.", + "Found my old photo albums in the attic, such memories.", + "Love making smoothies for breakfast, so refreshing and healthy.", + "Wash the car this weekend, it's covered in dust.", + "Spent the afternoon painting, lost track of time.", + "The dog park was full of happy dogs, running and playing.", + "Buy a new alarm clock, mine stopped working.", + "Love watching the sunset from my balcony, a daily highlight.", + "Went to the farmers' market this morning, got fresh produce.", + "The moon is so bright tonight, lighting up the whole yard.", + "Fix the leaky faucet in the bathroom, the dripping is annoying.", + "Had a fun game night with friends, lots of laughs.", + "Love the scent of fresh flowers, brightens my day.", + "Found a great recipe for dinner, can't wait to try it.", + "Take out the trash, the bin is overflowing.", + "The backyard is a great place to relax, especially in the evening.", + "Love the taste of fresh fruit, so juicy and sweet.", + "Organize my bookshelf, it's starting to overflow.", + "Went for a run in the park, the fresh air was invigorating.", + "Bought a new pillow for my bed, sleep should be even better now.", + "Planning a road trip next month, excited for the adventure.", + "The streetlights are glowing softly, creating a serene atmosphere.", + "Pick up my dry cleaning, need my favorite dress for an event.", + "Love the feeling of the sun on my skin, so warm and soothing.", + "Had a delicious cup of tea this morning, perfect start to the day.", + "Mow the lawn this weekend, it's getting too long.", + "Spent the day at the beach, the sound of waves was relaxing.", + "Love the sound of birds in the morning, nature's alarm clock.", + "Get a haircut soon, my hair is getting too long.", + "Had a great workout at the gym, feeling energized.", + "The sky is so clear and beautiful, perfect for photography.", + "Send some emails, catching up on correspondence.", + "Went for a drive in the countryside, the views were stunning.", + "Love the color of the leaves in fall, such a beautiful transformation.", + "Replace the batteries in the remote, it's not working properly.", + "Had a productive day at work, accomplished a lot.", + "Love trying new recipes, cooking is so much fun.", + "Update my calendar, lots of events coming up.", + "The garden looks so green after the rain, so refreshing.", + "Had a relaxing afternoon nap, felt so good.", + "Buy some new clothes, need a wardrobe update.", + "Love the quietness of the morning, so peaceful.", + "Had a fun time at the zoo, the animals were fascinating.", + "Plan my next vacation, thinking of going somewhere tropical.", + "Love the feeling of clean floors, makes the house feel fresh.", + "Had a nice chat with my neighbor, they're really friendly.", + "Make a grocery list, running low on essentials.", + "Love the coziness of my living room, perfect for movie nights.", + "Had a great time at the concert, the music was amazing.", + "Clean the windows, they're looking a bit dirty.", + "Love the taste of homemade bread, so much better than store-bought.", + "Had a peaceful evening at home, watched a good movie.", + "Enjoyed a quiet evening reading my favorite book, so relaxing.", + "Organize my kitchen pantry this weekend, it's getting messy.", + ], + relevant_noise=[ + "My neighbor at 123 Darwin Avenue threw a huge party last night.", + "I used to live at 456 Elm Street when I was a kid.", + "We visited my aunt at 789 Maple Lane during the holidays.", + "There's a beautiful park near 101 Birch Road that we often visit.", + "The new bakery on 234 Pine Street has the best pastries.", + "Our family friend lives at 567 Oak Avenue and has a lovely garden.", + "I received a package meant for 890 Cedar Drive by mistake.", + "The house at 345 Willow Lane is up for sale.", + "I walked past 678 Cherry Street on my way to work.", + "We had a great barbecue at 901 Ash Boulevard last summer.", + "My cousin just moved to 123 Birch Drive and loves it there.", + "There's a nice coffee shop at 456 Maple Street that I frequent.", + "My best friend grew up at 789 Elm Avenue and has many stories.", + "We held our annual family reunion at 101 Oak Drive.", + "There's a new gym opening at 234 Cedar Lane next month.", + "The house at 567 Pine Boulevard has a fantastic view.", + "I left my umbrella at 890 Willow Street last week.", + "The kids love playing at the park on 345 Ash Avenue.", + "We had our wedding reception at 678 Birch Road.", + "My grandparents lived at 901 Maple Lane for over 50 years.", + "My friend Sarah loves pizza, especially from Joe's Pizzeria.", + "For dinner last night, we had spaghetti with garlic bread.", + "At the new restaurant, I tried sushi for the first time.", + "My mom makes the best chocolate cake, hands down.", + "During our trip, we had fresh seafood by the beach.", + "My brother's favorite snack is a peanut butter and jelly sandwich.", + "We enjoyed a delicious brunch with pancakes and bacon.", + "My dad prefers his steak well-done with a side of mashed potatoes.", + "We all shared a large bowl of popcorn during the movie night.", + "I had a refreshing fruit salad for lunch yesterday.", + "My cousin always orders fried chicken when we eat out.", + "The pasta primavera at the Italian place was amazing.", + "We celebrated with a big slice of cheesecake each.", + "My grandma's homemade soup is perfect on a cold day.", + "For breakfast, I usually have oatmeal with fresh berries.", + "We had a wonderful Thanksgiving dinner with all the trimmings.", + "I tried a new recipe for tacos, and it was a hit.", + "My niece loves ice cream, especially chocolate flavor.", + "We had a picnic with sandwiches and lemonade by the lake.", + "The bakery's croissants were buttery and delicious.", + "I woke up at 7am this morning to go for a run.", + "We have a meeting scheduled at 10:30am tomorrow.", + "Dinner is usually served at our house around 6pm.", + "The concert starts at 8pm, so we should leave by 7.", + "Our flight departs at 9:15am, so we need to be at the airport early.", + "I usually get off work at 5pm and head straight home.", + "The train to the city leaves at 7:45am sharp.", + "We had a family gathering at noon to celebrate the holiday.", + "The fireworks show begins at 9pm every Fourth of July.", + "The library closes at 8pm, so let's hurry up.", + "I went for a walk at 6am to enjoy the sunrise.", + "We have a reservation at the restaurant for 7:30pm.", + "The store opens at 9am, perfect for early shopping.", + "Our appointment is at 3pm, don't forget to bring the documents.", + "The meeting was postponed to 2pm due to unforeseen circumstances.", + "I usually have lunch around 1pm during weekdays.", + "The gym class starts at 5:30pm, be there on time.", + "The movie premiere is at 7pm, let's get good seats.", + "My alarm goes off at 6:30am every morning.", + "The football match kicks off at 4pm this Sunday.", + "I recently visited Paris, France, and it was beautiful.", + "My best friend, Emily Johnson, is moving to New York City.", + "We went hiking in the Rocky Mountains last summer.", + "I met John Smith at a conference last year.", + "Our family vacationed in San Diego, California, last year.", + "I work with a colleague named Alice Brown, who is very talented.", + "We spent a weekend exploring Washington, D.C.", + "My old neighbor, Michael Davis, just got married.", + "We traveled to Tokyo, Japan, for a cultural experience.", + "My cousin, Laura Wilson, is a great cook.", + "I attended a workshop in Boston, Massachusetts.", + "My friend, David Lee, is an excellent guitarist.", + "We visited the Grand Canyon during our road trip.", + "I have a mentor named Sarah Thomas, who is very inspiring.", + "Our trip to London, England, was unforgettable.", + "My neighbor, Robert Martinez, has a beautiful garden.", + "We took a cruise to the Bahamas last winter.", + "My colleague, Jessica White, received an award for her work.", + "We toured the museums in Berlin, Germany, last spring.", + "I recently met a writer named Charles Moore at a book signing.", + ], + embeddable_noise=EmbeddableNoise( + addresses=[ + "Can you deliver this to
?", + "I'd like to order this to
.", + "Please send this to
.", + "I need this delivered to
.", + "The order should go to
.", + "Please arrange for this to be delivered to
.", + "Can the delivery be made to
?", + "I'd like this sent to
.", + "The meal needs to go to
.", + "Please have this dropped off at
.", + "Make sure this arrives at
.", + "I want this shipped to
.", + "This should be sent to
.", + "Deliver this order to
.", + "I would like this to be delivered to
.", + "Please ensure this is delivered to
.", + "Send this to
, please.", + "The delivery address is
.", + "This order is for
.", + "Can you make sure this gets to
?", + ], + foods=EmbeddableNoiseFoods( + starters=[ + "I'd like to order .", + "Can I have with that?", + "I'd like to start with .", + "Can you add to my order?", + "Please include as an appetizer.", + "Can you add to that?", + "Please add to my meal.", + ], + main=[ + "Please add
to my order.", + "I want
as my main dish.", + "For my main course, I'll have
.", + "I'll have
with a side.", + "I'd like
for my entrée.", + "I'd like to order
for dinner.", + "I'd like
as my main course.", + ], + desserts=[ + "I'd like for dessert.", + "Please include in my order.", + "I'll take for dessert.", + "I'd like to finish with .", + "For dessert, I'll have .", + "I'll take to finish.", + "For dessert, I'll have .", + ], + drinks=[ + "I'd like to order beverage.", + "Can I have with that?", + "Please include beverage with my meal.", + "I'd like to add to my order.", + "Can you add beverage to that?", + "I'll take with my meal.", + "Please add beverage to my order.", + ], + ), + times=[ + "I need this delivered by