diff --git a/mesmerizing-lightyears/.github/workflows/lint.yaml b/mesmerizing-lightyears/.github/workflows/lint.yaml new file mode 100644 index 0000000..7f67e80 --- /dev/null +++ b/mesmerizing-lightyears/.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/mesmerizing-lightyears/.gitignore b/mesmerizing-lightyears/.gitignore new file mode 100644 index 0000000..11b4ee9 --- /dev/null +++ b/mesmerizing-lightyears/.gitignore @@ -0,0 +1,33 @@ +# 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 +# vim +*.swp diff --git a/mesmerizing-lightyears/.pre-commit-config.yaml b/mesmerizing-lightyears/.pre-commit-config.yaml new file mode 100644 index 0000000..62d5d95 --- /dev/null +++ b/mesmerizing-lightyears/.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-format + - id: ruff diff --git a/mesmerizing-lightyears/LICENSE.txt b/mesmerizing-lightyears/LICENSE.txt new file mode 100644 index 0000000..dde7105 --- /dev/null +++ b/mesmerizing-lightyears/LICENSE.txt @@ -0,0 +1,7 @@ +Copyright 2024 Gustav Odinger, Berkin İlkan Seçkin, Jaspe Michael Ingabire, Mouelle Ewane Richard C., Ifeanyichukwu Goodness + +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/mesmerizing-lightyears/README.md b/mesmerizing-lightyears/README.md new file mode 100644 index 0000000..f4cb0e0 --- /dev/null +++ b/mesmerizing-lightyears/README.md @@ -0,0 +1,220 @@ +# Python Adventures + +https://github.com/user-attachments/assets/8f392225-6d21-464f-9e55-bc74ca643df3 + +*🔈Volume on* + +Python Adventures is a Discord game bot where you learn about Python features while progressing your character through a map filled with challenges and secrets! + +## Table of contents +- [Python Adventures](#python-adventures) + - [Table of contents](#table-of-contents) + - [Theme: Information Overload](#theme-information-overload) +- [Gameplay](#gameplay) + - [Map](#map) + - [Navigation](#navigation) + - [Levels](#levels) + - [Limited lives](#limited-lives) + - [Code evaluation](#code-evaluation) + - [Hints](#hints) + - [Success/defeat/win screens](#successdefeatwin-screens) +- [Software architecture](#software-architecture) + - [Level/questions](#levelquestions) + - [Adding a new level](#adding-a-new-level) + - [Adding a new question type](#adding-a-new-question-type) + - [Modifying default behavior](#modifying-default-behavior) +- [Setup instructions](#setup-instructions) + - [Discord API Token](#discord-api-token) + - [How to Obtain a Discord API Token](#how-to-obtain-a-discord-api-token) + - [Discord Developer Portal Settings](#discord-developer-portal-settings) + - [Installation and Setup](#installation-and-setup) +- [Contributions](#contributions) + + +## Theme: Information Overload +Python Adventures immerses players in a learning environment filled with information-rich levels. Each level combines multiple-choice questions and intricate coding challenges, demanding players process and apply vast amounts of information quickly and accurately. The special code golfing levels take this to the next level by requiring players to deal with dense, compact code — all while keeping it light and playful. + +# Gameplay + +Follow the main character Maria as she sets out on an epic journey to becoming a true Pythonista! The main storyline of the game consists of 11 levels, where completing one unlocks the next. + +## Map + +![Map](presentation/levels.png) + +The levels 1-11 consist of both multiple-choice questions and code writing challenges, to make learning exciting and really challenge your unstanding of the topics at hand. + +There are also three *special* levels A, B and C, outside of the main storyline, which tackle code golfing. If your brain doesn't get overloaded from the compact information in oneliners and hacky ways of writing code, these levels perfect for you! + +### Navigation +![Map navigation](/presentation/map-navigation.png) + +To move between levels in Python Adventures, we have created a vibrant map that can be easily navigated using Discord's interaction buttons. The map is dynamically generated for the user, taking into account factors such as the following: +- **Unlocked levels**, including special levels +- **Completed levels** +- **Current player position**, persisted between sessions +- **Player display name**, for the name tag + +In order to not clutter up the chat, navigation simply edits the original interaction response embed with the new map state. Buttons are also dynamically enabled/disabled based on whether or not an action can be taken. For example, if a tree is in the way, moving that direction will be disabled. + +## Levels +### Limited lives +![Lives](bot/assets/guide-hearts.png) + +With three lives comes a maximum of three mistakes before you have to restart the level. This keeps the game exciting and turns the difficulty of even the multiple choice questions up a notch. + +### Code evaluation +The most important part of learning to code is writing code and actually trying things yourself. Because of this, we have made code writing an integral part of the levels, providing a Code Playground to test run your code before submitting. And once submitted, we run a battery of unit tests on the code to ensure it passes the requirements. + +By utilizing the [Tio.run](https://tio.run) API to evaluate code, we are able to evaluate and test user code without needing to set up a sandboxed environment and without risking malicious input causing issues for the machine running the bot. + +### Hints +Sometimes you get stuck on a problem. While revealing the answer straight away won't help you much in learning, getting a hint or two in the right direction can be a game changer. We have provided hints for all questions that can be accessed one at a time by pressing the **Hint** button: + +![Hints](presentation/hints.png) + +### Success/defeat/win screens +![Status screens](presentation/level-statuses.png) + +With vibrant success/fail messages depending on how a level went for the player, we hope to keep the player engaged and excited to move toward the next goal. The images above are some of these and are displayed in the following situations: +- **Level failed**: the user fails a level +- **Level success**: the user completes a level +- **New level unlocked**: displayed after "level success" when the B or C level is unlocked +- **You win**: displayed when completing the final level of the game (level C) together with a message from the developers :) + +# Software architecture + +In order to create a game that is easy to maintain and simple to add new features to without breaking existing ones, we have put a lot of effort into designing a software architecture for the game that will allow precisely that. + +## Level/questions + +Here is a diagram that explains the general level-question software architecture: + +![CJ architecture](presentation/CJ_architecture.png) + +### Adding a new level + +This is how a new level is added: +- Subclass `Level` and set the desired attributes +- Adding `Level.register()` in the `register_all_levels()` function +- Add the level's questions in `questions.json` + +The new level is now accessible and ready to be used anywhere in the bot through the `Controller` class! The map description, buttons and functionality at the provided coordinte will automatically match what was provided in the `Level` class. Better yet, all questions in `questions.json` will be automatically parsed, loaded, and ready to be used. + +### Adding a new question type + +At one point while developing the game, we decided to add a code golf class. In a few minutes, the class was ready to go! By subclassing `WriteCodeQuestion` (which itself is a subclass of `Question`), the code golf question type got all the code execution, unit test and normal "question" logic from the `WriteCodeQuestion` and `Question` classes. + +This is how a new question type is added: +- Subclass `Question` and set the desired attributes +- *Optional*: overwrite functions for question parsing, running, on_success, on_fail and other methods +- Add the question type to `Question.view` +- Add the question type to `question_factory` + +Your question type is now fully supported by the bot and questions of the type can be added to `questions.json`! + +### Modifying default behavior + +One major feature of the software architecture at hand, is the ability to extend and overwrite default functionality in question types and levels without touching the base classes. Here are a few of the supported overwrites: + +**Question** +- `check_response`: logic to check if a given answer is correct +- `get_embed_description` and `embed`: customize the embed send for the question + +**QuestionView** +- `on_quit`: called when the user presses "Quit" +- `on_success`: called when the user succeeds with a question +- `on_fail`: called when the user answers a question incorrectly + +**Level** +- `run`: asks the user questions, displays status screen when level is over and then returns the user to the map +- `on_failure`: called when the user fails a level +- `on_success`: called when the user completes a level +- `_success_page` and `_success_more_pages`: used to customize the embeds being sent when the user completes the level, for presenting newly unlocked levels or win screens + +These customization options have been crucial for the following and more: showing a quickguide about lives before the first level, unlocking special levels at the right times and customizing the success screens of levels to showcase the current status. In the future, these features would also be great for integrating more story into the game or giving the player certain rewards for completing levels, questions or other tasks. + +# Setup instructions +## Discord API Token +What is a Discord API Token? +A Discord API Token is a unique identifier used to authenticate requests to the Discord API. It acts as a password for your bot, allowing it to interact with Discord's servers, join channels, send messages, and perform other actions as defined by the Discord API. + +## How to Obtain a Discord API Token +1. Create a New Application: + - Go to the [***Discord Developer Portal***](https://discord.com/developers/applications) + - Click on ***New Application*** + - Give your application a name and click **Create**. + +2. Create a Bot: + - Navigate to the ***Bot*** section in the sidebar. + - Click on "Add Bot" and confirm by clicking ***Yes, do it!*** + +3. Copy the Token: + - Under the "TOKEN" section, click "Copy" to copy your bot's token. + - Keep this token secure and never share it publicly. If your token is exposed, you should regenerate it immediately. + +## Discord Developer Portal Settings + +1. Log in to **[Discord Developer Portal](https://discord.com/developers/)** +2. Create your new project with the ``New Application`` button. +3. Activate the ``PRESENCE INTENT``, ``SERVER MEMBERS INTENT``, ``MESSAGE CONTENT INTENT`` in the ``Bot > Privileged Gateway Intents`` section. +4. After creating your project, make the necessary settings in the ``Settings > OAuth2`` tab. + - Select the ``bot`` option from the OAuth2 URL Generator section + - Below, select the permissions you wish for the bot to have in the server. We recommend `Administrator` for testing purposes +5. After clicking on the ``bot``, you can choose the permissions your bot will have from the window that opens. + + **Recommended settings:** + ```` + Bot > General Permissions + - Manage Expressions + - Create Expressions + - View Channels + - View Server Insights + + Bot > Text Permissions + - Send Messages + - Manage Messages + - Embed Links + - Attach Files + - Use External Emojis + - Use External Stickers + - Add Reactions + - Use Slash Commands + - Use Embeded activites + - Create Polls + ```` + - Depending on the options you set, you can add your bot to your server by opening the ``GENERATED URL`` in your browser, authenticating with discord, and adding it to a server of your choosing. + - Make sure you give your bot enough room to play. + + +## Installation and Setup +To get your Discord bot up and running, follow these steps: +1. **Clone the repository**: `git clone https://github.com/gustavwilliam/cj11-mesmerizing-meteors.git && cd cj11-mesmerizing-meteors` + +2. **Install Requirements**: + > - You can create a virtual environment by `python -m venv .venv` + > - Activate it by `source .venv/bin/activate` then pursue the setup + +Install the dependencies using `pip install -r requirements.txt`. If you are developing the bot and not just running it, also consider installing dev requirements: `pip install -r dev-requirements.txt`. + +3. **Set Up Your Environment Variables**: + - Create a .env file in the root of your project and add your Discord API Token: `DISCORD_BOT_KEY="your-discord-token-here"` + +4. **Update Emoji Config**: + - Under `bot/assets/icons` you will find all the required emojis for the bot + - Add all these emojis to a server that your bot will be invited to + - Copy the ID of every emoji (send the custom emoji in a Discord channel and add `\` before it to see the ID. It will look something like this: `<:arrowright:1265077270515552339>` + - Update the IDs of every emoji in `bot/config.py` + +5. **Run the Bot**: `python bot/main.py` + +# Contributions + +This is a rough overview of what each team member has contributed: +- @gustavwilliam: graphics, map functionality and question+level classes +- @Noble-47: database functionality +- @jspmic: level questions, documentation and a (sadly not merged in time) help command +- @HeavenMercy: game intro video in this readme, linting and dotenv usage +- @Deja-Vu1: basic bot setup and discord developer portal instructions + +Furthermore, each team member has contributed with valuable ideas and feedback throughout the development process. diff --git a/mesmerizing-lightyears/bot/README b/mesmerizing-lightyears/bot/README new file mode 100644 index 0000000..8df438b --- /dev/null +++ b/mesmerizing-lightyears/bot/README @@ -0,0 +1 @@ +Folder Containing the project itself diff --git a/mesmerizing-lightyears/bot/__init__.py b/mesmerizing-lightyears/bot/__init__.py new file mode 100644 index 0000000..d01a1b6 --- /dev/null +++ b/mesmerizing-lightyears/bot/__init__.py @@ -0,0 +1 @@ +# bot __init__ file diff --git a/mesmerizing-lightyears/bot/assets/game-win.png b/mesmerizing-lightyears/bot/assets/game-win.png new file mode 100644 index 0000000..3761679 Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/game-win.png differ diff --git a/mesmerizing-lightyears/bot/assets/guide-hearts.png b/mesmerizing-lightyears/bot/assets/guide-hearts.png new file mode 100644 index 0000000..71882ab Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/guide-hearts.png differ diff --git a/mesmerizing-lightyears/bot/assets/hearts_0.png b/mesmerizing-lightyears/bot/assets/hearts_0.png new file mode 100644 index 0000000..59196cb Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/hearts_0.png differ diff --git a/mesmerizing-lightyears/bot/assets/hearts_1.png b/mesmerizing-lightyears/bot/assets/hearts_1.png new file mode 100644 index 0000000..ff2ba95 Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/hearts_1.png differ diff --git a/mesmerizing-lightyears/bot/assets/hearts_2.png b/mesmerizing-lightyears/bot/assets/hearts_2.png new file mode 100644 index 0000000..fb7578b Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/hearts_2.png differ diff --git a/mesmerizing-lightyears/bot/assets/hearts_3.png b/mesmerizing-lightyears/bot/assets/hearts_3.png new file mode 100644 index 0000000..5a7cfa2 Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/hearts_3.png differ diff --git a/mesmerizing-lightyears/bot/assets/icons/arrow-down.png b/mesmerizing-lightyears/bot/assets/icons/arrow-down.png new file mode 100644 index 0000000..a440b56 Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/icons/arrow-down.png differ diff --git a/mesmerizing-lightyears/bot/assets/icons/arrow-left.png b/mesmerizing-lightyears/bot/assets/icons/arrow-left.png new file mode 100644 index 0000000..53ffefa Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/icons/arrow-left.png differ diff --git a/mesmerizing-lightyears/bot/assets/icons/arrow-right.png b/mesmerizing-lightyears/bot/assets/icons/arrow-right.png new file mode 100644 index 0000000..eb55682 Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/icons/arrow-right.png differ diff --git a/mesmerizing-lightyears/bot/assets/icons/arrow-up.png b/mesmerizing-lightyears/bot/assets/icons/arrow-up.png new file mode 100644 index 0000000..75413bd Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/icons/arrow-up.png differ diff --git a/mesmerizing-lightyears/bot/assets/icons/check.png b/mesmerizing-lightyears/bot/assets/icons/check.png new file mode 100644 index 0000000..3be709e Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/icons/check.png differ diff --git a/mesmerizing-lightyears/bot/assets/icons/cross.png b/mesmerizing-lightyears/bot/assets/icons/cross.png new file mode 100644 index 0000000..acabd5f Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/icons/cross.png differ diff --git a/mesmerizing-lightyears/bot/assets/icons/hint.png b/mesmerizing-lightyears/bot/assets/icons/hint.png new file mode 100644 index 0000000..cc69a5d Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/icons/hint.png differ diff --git a/mesmerizing-lightyears/bot/assets/icons/letter_a.png b/mesmerizing-lightyears/bot/assets/icons/letter_a.png new file mode 100644 index 0000000..8dca0bd Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/icons/letter_a.png differ diff --git a/mesmerizing-lightyears/bot/assets/icons/letter_b.png b/mesmerizing-lightyears/bot/assets/icons/letter_b.png new file mode 100644 index 0000000..c26f618 Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/icons/letter_b.png differ diff --git a/mesmerizing-lightyears/bot/assets/icons/letter_c.png b/mesmerizing-lightyears/bot/assets/icons/letter_c.png new file mode 100644 index 0000000..2498e12 Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/icons/letter_c.png differ diff --git a/mesmerizing-lightyears/bot/assets/icons/letter_d.png b/mesmerizing-lightyears/bot/assets/icons/letter_d.png new file mode 100644 index 0000000..0770e72 Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/icons/letter_d.png differ diff --git a/mesmerizing-lightyears/bot/assets/icons/letter_e.png b/mesmerizing-lightyears/bot/assets/icons/letter_e.png new file mode 100644 index 0000000..04dfaf0 Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/icons/letter_e.png differ diff --git a/mesmerizing-lightyears/bot/assets/icons/letter_f.png b/mesmerizing-lightyears/bot/assets/icons/letter_f.png new file mode 100644 index 0000000..7c22edd Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/icons/letter_f.png differ diff --git a/mesmerizing-lightyears/bot/assets/level-fail.png b/mesmerizing-lightyears/bot/assets/level-fail.png new file mode 100644 index 0000000..f850123 Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/level-fail.png differ diff --git a/mesmerizing-lightyears/bot/assets/level-success.png b/mesmerizing-lightyears/bot/assets/level-success.png new file mode 100644 index 0000000..6706ad6 Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/level-success.png differ diff --git a/mesmerizing-lightyears/bot/assets/map/map-done-a.png b/mesmerizing-lightyears/bot/assets/map/map-done-a.png new file mode 100644 index 0000000..cb76655 Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/map/map-done-a.png differ diff --git a/mesmerizing-lightyears/bot/assets/map/map-done-ab.png b/mesmerizing-lightyears/bot/assets/map/map-done-ab.png new file mode 100644 index 0000000..c9941b3 Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/map/map-done-ab.png differ diff --git a/mesmerizing-lightyears/bot/assets/map/map-done-abc.png b/mesmerizing-lightyears/bot/assets/map/map-done-abc.png new file mode 100644 index 0000000..8900d6e Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/map/map-done-abc.png differ diff --git a/mesmerizing-lightyears/bot/assets/map/map-done-ac.png b/mesmerizing-lightyears/bot/assets/map/map-done-ac.png new file mode 100644 index 0000000..9121217 Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/map/map-done-ac.png differ diff --git a/mesmerizing-lightyears/bot/assets/map/map-done-b.png b/mesmerizing-lightyears/bot/assets/map/map-done-b.png new file mode 100644 index 0000000..83f9289 Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/map/map-done-b.png differ diff --git a/mesmerizing-lightyears/bot/assets/map/map-done-bc.png b/mesmerizing-lightyears/bot/assets/map/map-done-bc.png new file mode 100644 index 0000000..8fe7569 Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/map/map-done-bc.png differ diff --git a/mesmerizing-lightyears/bot/assets/map/map-done-c.png b/mesmerizing-lightyears/bot/assets/map/map-done-c.png new file mode 100644 index 0000000..11c5631 Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/map/map-done-c.png differ diff --git a/mesmerizing-lightyears/bot/assets/map/map-done.png b/mesmerizing-lightyears/bot/assets/map/map-done.png new file mode 100644 index 0000000..b9fd9ae Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/map/map-done.png differ diff --git a/mesmerizing-lightyears/bot/assets/map/map-lvl1.png b/mesmerizing-lightyears/bot/assets/map/map-lvl1.png new file mode 100644 index 0000000..2a8e4bd Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/map/map-lvl1.png differ diff --git a/mesmerizing-lightyears/bot/assets/map/map-lvl10-a.png b/mesmerizing-lightyears/bot/assets/map/map-lvl10-a.png new file mode 100644 index 0000000..67a1bc8 Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/map/map-lvl10-a.png differ diff --git a/mesmerizing-lightyears/bot/assets/map/map-lvl10.png b/mesmerizing-lightyears/bot/assets/map/map-lvl10.png new file mode 100644 index 0000000..dccd841 Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/map/map-lvl10.png differ diff --git a/mesmerizing-lightyears/bot/assets/map/map-lvl11-a.png b/mesmerizing-lightyears/bot/assets/map/map-lvl11-a.png new file mode 100644 index 0000000..bfd126a Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/map/map-lvl11-a.png differ diff --git a/mesmerizing-lightyears/bot/assets/map/map-lvl11.png b/mesmerizing-lightyears/bot/assets/map/map-lvl11.png new file mode 100644 index 0000000..95d00ec Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/map/map-lvl11.png differ diff --git a/mesmerizing-lightyears/bot/assets/map/map-lvl2.png b/mesmerizing-lightyears/bot/assets/map/map-lvl2.png new file mode 100644 index 0000000..ee2530f Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/map/map-lvl2.png differ diff --git a/mesmerizing-lightyears/bot/assets/map/map-lvl3.png b/mesmerizing-lightyears/bot/assets/map/map-lvl3.png new file mode 100644 index 0000000..226afb5 Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/map/map-lvl3.png differ diff --git a/mesmerizing-lightyears/bot/assets/map/map-lvl4.png b/mesmerizing-lightyears/bot/assets/map/map-lvl4.png new file mode 100644 index 0000000..a61a24d Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/map/map-lvl4.png differ diff --git a/mesmerizing-lightyears/bot/assets/map/map-lvl5-a.png b/mesmerizing-lightyears/bot/assets/map/map-lvl5-a.png new file mode 100644 index 0000000..9738cf4 Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/map/map-lvl5-a.png differ diff --git a/mesmerizing-lightyears/bot/assets/map/map-lvl5.png b/mesmerizing-lightyears/bot/assets/map/map-lvl5.png new file mode 100644 index 0000000..4d9cd53 Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/map/map-lvl5.png differ diff --git a/mesmerizing-lightyears/bot/assets/map/map-lvl6-a.png b/mesmerizing-lightyears/bot/assets/map/map-lvl6-a.png new file mode 100644 index 0000000..7b9998d Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/map/map-lvl6-a.png differ diff --git a/mesmerizing-lightyears/bot/assets/map/map-lvl6.png b/mesmerizing-lightyears/bot/assets/map/map-lvl6.png new file mode 100644 index 0000000..4220554 Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/map/map-lvl6.png differ diff --git a/mesmerizing-lightyears/bot/assets/map/map-lvl7-a.png b/mesmerizing-lightyears/bot/assets/map/map-lvl7-a.png new file mode 100644 index 0000000..6452d21 Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/map/map-lvl7-a.png differ diff --git a/mesmerizing-lightyears/bot/assets/map/map-lvl7.png b/mesmerizing-lightyears/bot/assets/map/map-lvl7.png new file mode 100644 index 0000000..947c3c2 Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/map/map-lvl7.png differ diff --git a/mesmerizing-lightyears/bot/assets/map/map-lvl8-a.png b/mesmerizing-lightyears/bot/assets/map/map-lvl8-a.png new file mode 100644 index 0000000..9c04957 Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/map/map-lvl8-a.png differ diff --git a/mesmerizing-lightyears/bot/assets/map/map-lvl8.png b/mesmerizing-lightyears/bot/assets/map/map-lvl8.png new file mode 100644 index 0000000..5f0f8a2 Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/map/map-lvl8.png differ diff --git a/mesmerizing-lightyears/bot/assets/map/map-lvl9-a.png b/mesmerizing-lightyears/bot/assets/map/map-lvl9-a.png new file mode 100644 index 0000000..adf657b Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/map/map-lvl9-a.png differ diff --git a/mesmerizing-lightyears/bot/assets/map/map-lvl9.png b/mesmerizing-lightyears/bot/assets/map/map-lvl9.png new file mode 100644 index 0000000..3c41978 Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/map/map-lvl9.png differ diff --git a/mesmerizing-lightyears/bot/assets/name-box.png b/mesmerizing-lightyears/bot/assets/name-box.png new file mode 100644 index 0000000..0f12bd4 Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/name-box.png differ diff --git a/mesmerizing-lightyears/bot/assets/player.png b/mesmerizing-lightyears/bot/assets/player.png new file mode 100644 index 0000000..9f893d9 Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/player.png differ diff --git a/mesmerizing-lightyears/bot/assets/title-art.png b/mesmerizing-lightyears/bot/assets/title-art.png new file mode 100644 index 0000000..c6af66d Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/title-art.png differ diff --git a/mesmerizing-lightyears/bot/assets/unlocked-b.png b/mesmerizing-lightyears/bot/assets/unlocked-b.png new file mode 100644 index 0000000..82f2901 Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/unlocked-b.png differ diff --git a/mesmerizing-lightyears/bot/assets/unlocked-c.png b/mesmerizing-lightyears/bot/assets/unlocked-c.png new file mode 100644 index 0000000..b75aed3 Binary files /dev/null and b/mesmerizing-lightyears/bot/assets/unlocked-c.png differ diff --git a/mesmerizing-lightyears/bot/config.py b/mesmerizing-lightyears/bot/config.py new file mode 100644 index 0000000..b1df336 --- /dev/null +++ b/mesmerizing-lightyears/bot/config.py @@ -0,0 +1,19 @@ +from enum import Enum + + +class Emoji(Enum): + """Configuration for custom Emojis.""" + + CHECK = "<:check:1265079659448766506>" + ARROW_LEFT = "<:arrowleft:1265077268951339081>" + ARROW_RIGHT = "<:arrowright:1265077270515552339>" + ARROW_UP = "<:arrowup:1265077271970975874>" + ARROW_DOWN = "<:arrowdown:1265077267965673587>" + HINT = "<:hint:1265996402891292675>" + CROSS = "<:exit:1265999816023080991>" + LETTER_A = "<:letter_a:1265996405164474368>" + LETTER_B = "<:letter_b:1265996406028636232>" + LETTER_C = "<:letter_c:1265996407647768716>" + LETTER_D = "<:letter_d:1265996409040277595>" + LETTER_E = "<:letter_e:1265996410453622945>" + LETTER_F = "<:letter_f:1265996412601241663>" diff --git a/mesmerizing-lightyears/bot/controller.py b/mesmerizing-lightyears/bot/controller.py new file mode 100644 index 0000000..696b354 --- /dev/null +++ b/mesmerizing-lightyears/bot/controller.py @@ -0,0 +1,51 @@ +from typing import TYPE_CHECKING, ClassVar + +if TYPE_CHECKING: + from levels import Level + + +class Controller: + """Manage levels and run them when requested.""" + + _instance = None + levels: ClassVar[list[type["Level"]]] = [] + + def __new__(cls, *args, **kwargs) -> "Controller": # noqa: ANN002, ANN003 + """Create a singleton instance of the Controller. + + This allows the Levels to sign up directly to the Controller. Since there will only be one + Controller instance in the program, the Levels can be sure that they are signing up to the + correct Controller, without needeing to pass it as an argument. + """ + if not isinstance(cls._instance, cls): + cls._instance = object.__new__(cls, *args, **kwargs) + return cls._instance + + def add_level(self, level: type["Level"]) -> None: + """Add a level to the controller. + + Raises a ValueError if a level with the same id or map position already exists. + """ + if level.id in self.levels: + raise ValueError + if level.map_position in [level.map_position for level in self.levels]: + raise ValueError + self.levels.append(level) + + def get_level(self, position: tuple[int, int]) -> type["Level"] | None: + """Get the level at a given map position, or return None if no level exists.""" + for level in self.levels: + if level.map_position == position: + return level + return None + + def get_level_by_id(self, id: int) -> type["Level"] | None: + """Get the level with the given id, or return None if no level exists.""" + for level in self.levels: + if level.id == id: + return level + return None + + def is_level(self, position: tuple[int, int]) -> bool: + """Check if a level exists at the given map position.""" + return any(level.map_position == position for level in self.levels) diff --git a/mesmerizing-lightyears/bot/database/.store.db b/mesmerizing-lightyears/bot/database/.store.db new file mode 100644 index 0000000..3c0bb93 Binary files /dev/null and b/mesmerizing-lightyears/bot/database/.store.db differ diff --git a/mesmerizing-lightyears/bot/database/__init__.py b/mesmerizing-lightyears/bot/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mesmerizing-lightyears/bot/database/database.py b/mesmerizing-lightyears/bot/database/database.py new file mode 100644 index 0000000..1f91173 --- /dev/null +++ b/mesmerizing-lightyears/bot/database/database.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import sqlite3 +from contextlib import closing +from pathlib import Path + +PATH = Path(__file__).parent # Path of the database + +SCHEMA = """ + CREATE TABLE IF NOT EXISTS player_detail( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NON NULL, + level INTEGER NON NULL DEFAULT 1, + score INTEGER NON NULL DEFAULT 0, + completed BOOLEAN NON NULL DEFAULT 1 + ); + + CREATE TABLE IF NOT EXISTS map( + username TEXT PRIMARY KEY, + coord_x INTEGER NON NULL, + coord_y INTEGER NON NULL + ); +""" + +sqlite3.register_adapter(bool, int) +sqlite3.register_converter("BOOLEAN", lambda v: bool(int(v))) + + +class Database: + """Class that handles interactions with the database.""" + + __table_name__ = None + + def __init__(self, name: str | None = None) -> None: + # Default name of the database + self.name = name or PATH.joinpath(".store.db") + + # Connection to the database + self.__connection = sqlite3.connect(self.name) + self.__connection.row_factory = sqlite3.Row + self.__cursor = self.__connection.cursor() + + # initialize database + self.__cursor.executescript(SCHEMA) + + @property + def connection(self) -> sqlite3.Connection: + """Connection property of database.""" + return self.__connection + + @property + def cursor(self) -> sqlite3.Cursor: + """Cursor property of database.""" + return closing(self.connection.cursor()) + + def execute_command(self, command: str, data: tuple = ()) -> bool: + """Execute the given command.""" + cursor = self.connection.cursor() + if cursor.execute(command, data): + self.connection.commit() # Commit the changes to the DB + return True + return False + + def disconnect(self) -> bool: + """Close the database connection.""" + return not bool(self.connection.close()) + + def __str__(self) -> str: + db_str = f"Database " + else: + db_str += ">" + return db_str + + +class Score(Database): + """SQL repository for scoresheet.""" + + __table_name__ = "player_detail" + + def fetch(self, level: int) -> list: + """Fetch level scoresheet.""" + with self.cursor as cursor: + cursor.execute( + """ + SELECT DISTINCT(username), score + FROM player_detail + WHERE level = ? + ORDER BY score DESC + """, + (level,), + ) + return cursor.fetchall() + + +class PlayerDetail(Database): + """SQL repository for player details.""" + + __table_name__ = "player_detail" + + def get(self, username: str) -> list: + """Load player's details from player_detail table.""" + with self.cursor as cursor: + cursor.execute( + """ + SELECT username, level, score, completed + FROM player_detail + WHERE username = ? + ORDER BY level ASC; + """, + (username,), + ) + + return cursor.fetchall() + + def insert(self, username: str, level: int, score: int, *, completed: bool = True) -> None: + """Insert into player_detail.""" + command = """ + INSERT INTO player_detail (username, level, score, completed) + VALUES (?, ?, ?, ?) + """ + + self.execute_command(command, (username, level, score, completed)) + + def insert_many(self, data: tuple[dict]) -> None: + """Insert many rows.""" + command = """ + INSERT INTO player_detail (username, level, score, completed) + VALUES (:username, :level, :score, :completed) + """ + + with self.cursor as cursor: + cursor.executemany(command, data) + self.connection.commit() + + def get_map_coordinates(self, username: str) -> tuple | None: + """Get map coordinate of user.""" + with self.cursor as cursor: + cursor.execute( + """ + SELECT coord_x, coord_y + FROM map + WHERE username = ? + """, + (username,), + ) + + row = cursor.fetchone() + if row: + return row["coord_x"], row["coord_y"] + return None + + def update_map_coordinates(self, username: str, coord_x: int, coord_y: int) -> None: + """Update map coordinate of user.""" + # check if user exists in database + with self.cursor as cursor: + cursor.execute( + """ + SELECT username + FROM map + WHERE username = ? + """, + (username,), + ) + + user = cursor.fetchone() + + if user is not None: + command = """ + UPDATE map + SET coord_x = ?, coord_y = ? + WHERE username = ? + """ + else: + command = """ + INSERT INTO map(coord_x, coord_y, username) + VALUES (?,?,?); + """ + + self.execute_command(command, (coord_x, coord_y, username)) diff --git a/mesmerizing-lightyears/bot/database/models/__init__.py b/mesmerizing-lightyears/bot/database/models/__init__.py new file mode 100644 index 0000000..1fae17b --- /dev/null +++ b/mesmerizing-lightyears/bot/database/models/__init__.py @@ -0,0 +1,4 @@ +from .player import PlayDetail, Player, PlayerRepo, PlayHistory +from .score import Score, ScoreSheet + +__all__ = ["Player", "PlayDetail", "PlayHistory", "PlayerRepo", "Score", "ScoreSheet"] diff --git a/mesmerizing-lightyears/bot/database/models/player.py b/mesmerizing-lightyears/bot/database/models/player.py new file mode 100644 index 0000000..3df6f16 --- /dev/null +++ b/mesmerizing-lightyears/bot/database/models/player.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +from typing import NamedTuple, Protocol + +from database.database import PlayerDetail + +LEVELS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14] +SPECIAL_LEVELS = [12, 13, 14] +MAX_LEVEL = 11 # Last level on the standard path of the game + + +class Position(NamedTuple): + """Holds coordinates Of User.""" + + x: int + y: int + + +class PlayerDB(Protocol): + """Template for player database.""" + + def get(self, username: str) -> list: + """Return player details.""" + + def insert(self, username: str, level: int, status: str, score: int) -> None: + """Insert into player table.""" + + +class PlayDetail: + """Class model for play session.""" + + def __init__( + self, + username: str, + level: int, + score: int, + *, + available: bool = True, + completed: bool = True, + ) -> None: + self.username = username + self.level = level + self.score = score + self.available = available + self.completed = completed + + def __eq__(self, other: PlayDetail) -> bool: + return self.username == other.username and self.level == other.level and self.completed == other.completed + + def __hash__(self) -> int: + return hash((self.username, self.level)) + + def __gt__(self, other: PlayDetail) -> bool: + if self.level == other.level: + return self.score > other.score + return LEVELS.index(self.level) > LEVELS.index(other.level) + + def __ge__(self, other: PlayDetail) -> bool: + if self.level == other.level: + return self.score >= other.score + return LEVELS.index(self.level) >= LEVELS.index(other.level) + + @property + def level_value(self) -> int | str: + """Return player level. + + All levels are internally stored as integers (1-14). + """ + return self.level + + def as_dict(self) -> dict: + """Represent play object as dictionary.""" + return {"username": self.username, "level": self.level, "score": self.score, "completed": self.completed} + + def summary(self) -> dict: + """Summary of play.""" + return { + "lvl_id": self.level, + "available": self.available, + "completed": self.completed, + } + + def is_special(self) -> bool: + """Check if play is for a special level.""" + return self.level in SPECIAL_LEVELS + + +class PlayHistory(list): + """Collection of PlayDetails.""" + + def __init__(self, *args, **kwargs) -> None: # noqa: ANN003 ANN002 + username = kwargs.pop("username") + super().__init__(*args, **kwargs) + self.new_plays = [] + self.username = username + + def __contains__(self, item: str | PlayDetail) -> bool: + if isinstance(item, str) and item.startswith("lvl"): + level = item[3:] # Remove leading 'lvl' + level = int(level) + return bool(any(play.level_value == level for play in self)) + + return super().__contains__(item) + + def __getitem__(self, index: int | str | slice) -> PlayDetail | list[PlayDetail]: + """Allow dynamic access to play history. + + Enables indexing by valid string or slice of string + >>> playhistory['lvl6'] # return plays with level == 6 + >>> playhistory['lvl5' : 'lvl7'] # return plays with levels in [5,6,7] + >>> playhistory['lvl7' : 'lvl2'] # returns an empty playhistory + >>> playhistory[0] # normal indexing + """ + if isinstance(index, str): + if index.startswith("lvl"): + level = index[3:] # Remove leading 'lvl' + level = int(level) + return self.get_plays_by_level(level) + error = f"Invalid string value: {index}" + raise ValueError(error) + + if isinstance(index, slice): + start, stop = index.start, index.stop + if isinstance(start, str) and isinstance(stop, str): + if start.startswith("lvl") and stop.startswith("lvl"): + start = int(start[3:]) # Remove leading 'lvl' + stop = int(stop[3:]) # Remove leading 'lvl' + return self.get_plays_by_level(start=start, stop=stop) + error = "Invalid string slice object : {index}. Use slice(lvlid, lvlid)" + raise ValueError(error) + + return super().__getitem__(index) + + def get_plays_by_level(self, start: int, stop: int | None = None) -> PlayHistory: + """Return plays for a given level or within a range of levels.""" + if stop is None: + return self.__class__([play for play in self if play.level_value == start], username=self.username) + + level_range = list(range(start, stop + 1)) + return self.__class__( + sorted([play for play in self if play.level_value in level_range]), + username=self.username, + ) + + @property + def max_level_played(self) -> PlayDetail: + """Return player's max level. + + Special levels not included. + """ + normal_level_played = [play for play in self if not play.is_special()] + if normal_level_played: + return max(normal_level_played) + return PlayDetail(username=self.username, level=1, score=0, completed=False) + + def append(self, item: PlayDetail) -> None: + """Save additions as new play before adding to history.""" + self.new_plays.append(item) + super().append(item) + + def summary(self, *, completed: bool = False) -> list[dict]: + """Return summary of play history.""" + if completed: + return [play.summary() for play in sorted(set(self)) if play.completed] + return [play.summary() for play in sorted(set(self))] + + +class Player: + """Object model for player.""" + + def __init__(self, username: str, details: list, coord: tuple | None = None) -> None: + self.username = username + self.history = PlayHistory([PlayDetail(**record) for record in details], username=username) + self.position = Position(*coord) if coord else Position(0, 0) + + def __repr__(self) -> str: + return f"Player" + + @property + def max_level(self) -> int: + """Returns max level.""" + return self.history.max_level_played.level if self.history else 0 + + @property + def next_level(self) -> int: + """Returns next level.""" + # current level is the max played level + play = self.history.max_level_played + if not play.completed: + return play.level + return play.level if play.level == MAX_LEVEL else play.level + 1 + + @property + def summary(self) -> list[dict]: + """Return player summary as a dictionary.""" + # Available levels is a list of: + # - completed level + # - player's next level + # - special levels unlocked + + # get completed levels + summary = self.history.summary(completed=True) + + # include next level + summary.append( + { + "lvl_id": self.next_level, + "available": True, + "completed": False, + }, + ) + + # If summary does not include completed special levels, add uncompleted but available special levels + special_levels = set(self.history.get_plays_by_level(start=12, stop=14)) + for special in special_levels: + if special.completed: + continue + summary.append(special.summary()) + + return summary + + @property + def new_data(self) -> list[dict]: + """Returns data added to history but not in database.""" + if not self.history: + return [{"username": self.username, "level": 1, "score": 0, "completed": False}] + return [play.as_dict() for play in self.history.new_plays] + + def unlock_level(self, level: int) -> None: + """Unlock level for player.""" + if (plays := self.history.get_plays_by_level(level)) and not any(filter(lambda play: play.available, plays)): + print(f"Level {level} is already unlocked") + return # Level is already unlocked + play = PlayDetail(username=self.username, level=level, score=0, available=True, completed=False) + self.history.append(play) + + def complete_level(self, level: int, score: int) -> None: + """Mark level as completed.""" + play = PlayDetail(username=self.username, level=level, score=score, completed=True) + self.history.append(play) + + def set_position(self, x: int, y: int) -> None: + """Set player position.""" + self.position = Position(x, y) + + def get_position(self) -> Position: + """Return player position.""" + return self.position + + +class PlayerRepo: + """Handles interaction between database and python models.""" + + def __init__(self, db: PlayerDB | None = None) -> None: + self.db = db or PlayerDetail() + + def get(self, username: str) -> Player: + """Get player detail from database.""" + details = self.db.get(username) + coordinate = self.db.get_map_coordinates(username) + return Player(username, details, coordinate) + + def save(self, player: Player) -> None: + """Save player detail to database.""" + data = player.new_data + if data: + data = tuple(data) + self.db.insert_many(data) + # clear new_data after saving + player.new_data.clear() + + coord_x, coord_y = player.get_position() + self.db.update_map_coordinates(player.username, coord_x, coord_y) diff --git a/mesmerizing-lightyears/bot/database/models/score.py b/mesmerizing-lightyears/bot/database/models/score.py new file mode 100644 index 0000000..f599104 --- /dev/null +++ b/mesmerizing-lightyears/bot/database/models/score.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from typing import Protocol + +from database.database import Score as ScoreRepo + + +class ScoreDB(Protocol): + """Template for Scores database.""" + + def fetch_scores(self) -> list: + """Fetch scores method.""" + + +class Score: + """Object wrapper for a row instance in score sheet.""" + + def __init__(self, username: str, score: int, level: int) -> None: + self.username = username + self.score = score + self.level = level + + def __gt__(self, other: Score) -> bool: + return self.score > other.score + + def __ge__(self, other: Score) -> bool: + return self.score >= other.score + + def __eq__(self, other: Score) -> bool: + return self.level == other.level and self.username == other.username and self.score == other.score + + def __hash__(self) -> None: + return hash((self.username, self.level)) + + def __repr__(self) -> str: + return f"Score" + + +class ScoreSheet: + """Scoresheet for `level`.""" + + def __init__(self, db: ScoreDB, level: int) -> None: + self.level = level + self._sheet: list[Score] = self.load_from_database(db) + + def __iter__(self) -> list[Score]: + return (score for score in sorted(self._sheet)) + + def __len__(self) -> int: + return len(self._sheet) + + def __repr__(self) -> str: + return f"ScoreSheet" + + def load_from_database(self, db: ScoreDB | None) -> list[Score]: + """Fetch all scores info for a particular level from database.""" + db = db or ScoreRepo() + return [Score(**row) for row in db.fetch_scores(level=self.level)] + + def add(self, username: str, score: int) -> None: + """Add user score to scoresheet.""" + # create score object + score_obj = Score(level=self.level, username=username, score=score) + + # if user already has a record on the scoresheet + # update the record + if score_obj in self._sheet: + self.update(score_obj) + else: + self._sheet.append(score_obj) + + def get(self, username: str) -> Score: + """Return the index for user's score.""" + for score in self._sheet: + if score.username == username: + return score + return None + + def remove(self, username: str) -> None: + """Remove username from level score sheet.""" + score = self.get(username) + if score: + self._sheet.remove(score) + + def _update(self, score_obj: Score, score: int) -> None: + """Update user score. + + Perform update only if user's new score is greater than the currently recorded score. + """ + if score_obj <= score: + return + + index = self._sheet.index(score_obj) + self._sheet[index].score = score + + # sort scoresheet + self._sheet.sort() + + def sort(self) -> list[Score]: + """Sort score sheet.""" + return sorted(self, reverse=True) diff --git a/mesmerizing-lightyears/bot/levels.py b/mesmerizing-lightyears/bot/levels.py new file mode 100644 index 0000000..d76561f --- /dev/null +++ b/mesmerizing-lightyears/bot/levels.py @@ -0,0 +1,402 @@ +import json +from pathlib import Path +from typing import Protocol + +import discord +from controller import Controller +from database.models.player import Player, PlayerRepo, Position +from discord import File, Interaction +from map import Map, generate_map, image_to_discord_file +from questions import Question, QuestionStatus, question_factory +from story import StoryPage, StoryView + +with Path.open(Path("bot/questions.json")) as f: + all_questions = json.load(f) + + +class Level(Protocol): + """Protocol that all levels must implement. + + Different level types can be created by subclassing this protocol. + All levels must run `Level.register()` to register the level class with the controller. + This should happen at startup. + + Instances of the levels that subclass this protocol are created + when a level is run. The data for the level is stored in the instance, + for example, the score of the specific run. When the run is over, + the instance is destroyed, so all data that should persist between runs + should be stored in a different place. + """ + + id: int + name: str + topic: str + map_position: tuple[int, int] + questions: list[Question] + hearts: int = 3 + + @classmethod + def register(cls) -> None: + """Register a Level class with the controller.""" + Controller().add_level(cls) + + def __init__(self) -> None: + """Fetch the data for the level.""" + self.fetch_level_data() + + def __str__(self) -> str: + return self.name + + def fetch_level_data(self) -> None: + """Fetch the questions, answers, and other data for the level and assign to questions attribute. + + The data is retrieved from bot/questions.json. The id of the level in the JSON file + must match the id attribute of the Level subclass for the information to be loaded. + If no questions are found for the level, a ValueError is raised. + """ + questions = all_questions.get(str(self.id)) + if questions is None: + raise ValueError("No questions found for level " + str(self.id)) + self.questions = [question_factory(**question_data) for question_data in questions] + + async def return_to_map(self, interaction: Interaction, map: Map) -> None: + """Return to the map after the level is exited.""" + position = map.player.get_position() + if position == (12, 1): # Move player out of B cave + position = Position(11, 1) + if position == (13, 4): # Move player out of C cave + position = Position(12, 4) + map.player.set_position(*position) + PlayerRepo().save(map.player) + + img = image_to_discord_file( + generate_map( + position=position, + player_username=interaction.user.name, + player_display_name=interaction.user.display_name, + ), + image_name := "image", + ) + embed = discord.Embed( + title=f"\U0001f5fa {interaction.user.display_name}'s map", + description="Press the arrow keys to move around.", + color=discord.Color.blurple(), + ) + embed.set_image(url=f"attachment://{image_name}.png") + await interaction.edit_original_response( + attachments=[img], + embed=embed, + view=Map(interaction.user), + ) + + def get_hearts_file(self) -> File: + """Return the path to the hearts image file.""" + return File( + Path(f"bot/assets/hearts_{self.hearts}.png"), + filename="hearts.png", # Must be exactly this. Question.embed() depends on it + ) + + def _level_unlocked(self, player: Player) -> bool: + """Return True if level is unlocked.""" + level = player.summary + return any(run["available"] for run in level if run["lvl_id"] == self.id) if level else False + + async def run(self, interaction: Interaction, map: Map) -> None: + """Run the level.""" + player = PlayerRepo().get(interaction.user.name) + if not self._level_unlocked(player): + await interaction.response.send_message("Level is locked. Keep playing to unlock it!", ephemeral=True) + return + + next_interaction = interaction + await next_interaction.response.defer() + for i, question in enumerate(self.questions): + while next_interaction is not None: + question_view = question.view(interaction.user) + await next_interaction.edit_original_response( + embed=question.embed(level=self, question_index=i + 1), + view=question_view, + attachments=[self.get_hearts_file()], + ) + await question_view.wait() + next_interaction = question_view.next_question_interaction + if question_view.status in [QuestionStatus.EXITED, QuestionStatus.CORRECT]: + break + if question_view.status == QuestionStatus.INCORRECT: + self.hearts -= 1 + if self.hearts == 0: + if next_interaction is None: + break + await self.on_failure(next_interaction) + break + if next_interaction is None or question_view.status in [QuestionStatus.EXITED, QuestionStatus.INCORRECT]: + break + if next_interaction is not None: + if question_view.status == QuestionStatus.CORRECT: + await self.on_success(next_interaction) + next_interaction = await self.return_to_map(interaction, map) + + async def on_failure(self, interaction: Interaction) -> Interaction: + """Call when the player fails the level.""" + story = StoryView( + pages=[ + StoryPage( + title="No lives left", + description="Try again and let's beat this level!", + image_path=Path("bot/assets/level-fail.png"), + color=discord.Color.red(), + ), + ], + continue_button_style=discord.ButtonStyle.danger, + user=interaction.user, + ) + await interaction.edit_original_response( + embed=story.first_embed(), + attachments=story.first_attachments(), + view=story, + ) + await story.wait() + if story.last_interaction is None: + raise ValueError + await story.last_interaction.response.defer() + return story.last_interaction + + def _success_page(self) -> StoryPage: + """Return the success pages for the level. + + This can be overridden by subclasses to add more pages to the success story, or to change the success page. + """ + return StoryPage( + title="Level complete!", + description="Well done completing the level.", + image_path=Path("bot/assets/level-success.png"), + color=discord.Color.green(), + ) + + def _success_more_pages(self) -> list[StoryPage]: + """Return additional success pages for the level. + + This can be overridden by subclasses to add more pages to the success story. + Defaults to an adding no additional pages. + """ + return [] + + async def _success_story(self, interaction: Interaction) -> Interaction: + story = StoryView( + pages=[self._success_page(), *(self._success_more_pages())], + continue_button_style=discord.ButtonStyle.success, + user=interaction.user, + ) + await interaction.edit_original_response( + embed=story.first_embed(), + attachments=story.first_attachments(), + view=story, + ) + await story.wait() + if story.last_interaction is None: + raise ValueError + await story.last_interaction.response.defer() + return story.last_interaction + + async def on_success(self, interaction: Interaction) -> Interaction: + """Call when the player succeeds the level.""" + player = PlayerRepo().get(interaction.user.name) + player.complete_level(level=self.id, score=1) + PlayerRepo().save(player) + return await self._success_story(interaction=interaction) + + +class Level1(Level): # noqa: D101 + id = 1 + name = "Level 1" + topic = "List Comprehensions" + map_position = (1, 0) + + async def run(self, interaction: Interaction[discord.Client], map: Map) -> None: + """Run the level. Overwrites to add a notice about hearts on first level.""" + await interaction.response.defer(thinking=True) + story = StoryView( + [ + StoryPage( + "Quick note about lives", + "You have three lives for every level. If you answer a question incorrectly, you will lose a life.\ + Lose all 3 and you lose the level. Keep track of your current lives in the top right corner of the question embed.", + Path("bot/assets/guide-hearts.png"), + ), + ], + user=interaction.user, + ) + await interaction.followup.send( + embed=story.first_embed(), + file=story.first_attachments()[0], + view=story, + ) + await story.wait() + if story.last_interaction is None: + return None + return await super().run(story.last_interaction, map) + + +class Level2(Level): # noqa: D101 + id = 2 + name = "Level 2" + topic = "Generators" + map_position = (3, 0) + + +class Level3(Level): # noqa: D101 + id = 3 + name = "Level 3" + topic = "Iterator" + map_position = (5, 0) + + +class Level4(Level): # noqa: D101 + id = 4 + name = "Level 4" + topic = "Function overloading" + map_position = (8, 0) + + async def on_success(self, interaction: Interaction[discord.Client]) -> Interaction: + """Unlock special level A.""" + player = PlayerRepo().get(interaction.user.name) + player.unlock_level(level=12) + PlayerRepo().save(player) + return await super().on_success(interaction) + + +class Level5(Level): # noqa: D101 + id = 5 + name = "Level 5" + topic = "Rewind Showcase" + map_position = (10, 0) + + +class Level6(Level): # noqa: D101 + id = 6 + name = "Level 6" + topic = "File Handling" + map_position = (11, 1) + + +class Level7(Level): # noqa: D101 + id = 7 + name = "Level 7" + topic = "Regular Expressions" + map_position = (11, 3) + + +class Level8(Level): # noqa: D101 + id = 8 + name = "Level 8" + topic = "OOP (Part I)" + map_position = (11, 5) + + +class Level9(Level): # noqa: D101 + id = 9 + name = "Level 9" + topic = "OOP (Part II)" + map_position = (10, 6) + + +class Level10(Level): # noqa: D101 + id = 10 + name = "Level 10" + topic = "Quizmaster's Reflection" + map_position = (8, 6) + + +class Level11(Level): # noqa: D101 + id = 11 + name = "Level 11" + topic = "Final Insight Odyssey" + map_position = (6, 6) + + async def on_success(self, interaction: Interaction[discord.Client]) -> Interaction: + """Unlock special level B.""" + player = PlayerRepo().get(interaction.user.name) + player.unlock_level(level=13) + PlayerRepo().save(player) + return await super().on_success(interaction) + + def _success_more_pages(self) -> list[StoryPage]: + return [ + *super()._success_more_pages(), + StoryPage( + title="Special level B unlocked!", + description="You have completed the final level of the normal path of the game. \ + Now it's time for you to prove your skills in the *special levels*.", + image_path=Path("bot/assets/unlocked-b.png"), + color=discord.Color.green(), + ), + ] + + +class LevelA(Level): # noqa: D101 + id = 12 + name = "Level A" + topic = "Quadratic Quest" + map_position = (8, -2) + + +class LevelB(Level): # noqa: D101 + id = 13 + name = "Level B" + topic = "Fusion Master" + map_position = (12, 1) + + async def on_success(self, interaction: Interaction[discord.Client]) -> Interaction: + """Unlock special level C.""" + player = PlayerRepo().get(interaction.user.name) + player.unlock_level(level=14) + PlayerRepo().save(player) + return await super().on_success(interaction) + + def _success_more_pages(self) -> list[StoryPage]: + return [ + *super()._success_more_pages(), + StoryPage( + title="Special level C unlocked!", + description="You have completed a special level. Complete the newly unlocked *special level C* \ + to complete the whole game! This is the final level.", + image_path=Path("bot/assets/unlocked-c.png"), + color=discord.Color.green(), + ), + ] + + +class LevelC(Level): # noqa: D101 + id = 14 + name = "Level C" + topic = "Final Frontier" + map_position = (13, 4) + + def _success_page(self) -> StoryPage: + return StoryPage( + title="You finished Python Adventures!", + description="You have completed all levels of the game — well done! While this may be the end of \ + Python Adventures, it is only the beginning of *your* Python adventure. \ + Keep coding and keep learning!\n\nThank you for playing our game. We hope you enjoyed it :)\n\ + ~ Mesmerizing Meteors", + image_path=Path("bot/assets/game-win.png"), + color=discord.Color.yellow(), + ) + + +def register_all_levels() -> None: + """Register all levels with the controller.""" + Level1.register() + Level2.register() + Level3.register() + Level4.register() + Level5.register() + Level6.register() + Level7.register() + Level8.register() + Level9.register() + Level10.register() + Level11.register() + LevelA.register() + LevelB.register() + LevelC.register() diff --git a/mesmerizing-lightyears/bot/main.py b/mesmerizing-lightyears/bot/main.py new file mode 100644 index 0000000..bbedd82 --- /dev/null +++ b/mesmerizing-lightyears/bot/main.py @@ -0,0 +1,124 @@ +from os import getenv +from pathlib import Path + +import async_tio +import discord +from controller import Controller +from discord.ext import commands +from dotenv import load_dotenv +from levels import register_all_levels +from map import Map, generate_map, image_to_discord_file +from story import StoryPage, StoryView +from utils.eval import eval_python + +load_dotenv() + +# Identify the bot +intents = discord.Intents.all() +bot = commands.Bot(command_prefix="!", intents=intents) + + +# Basic slash command +@bot.tree.command(name="ping", description="Test command!") +async def merhaba(interaction: discord.Interaction) -> None: + """Test command.""" + await interaction.response.send_message(f"Pong! {interaction.user.display_name}", ephemeral=True) + + +@bot.tree.command(name="play", description="Start the Python Adventures game!") +async def get_map(interaction: discord.Interaction) -> None: + """Start the game.""" + await interaction.response.defer(thinking=True) + story = StoryView( + [ + StoryPage( + "Welcome to Python Adventures!", + "Mesmerizing Meteors wish you the best of luck in your adventures to come", + Path("bot/assets/title-art.png"), + ), + ], + user=interaction.user, + ) + await interaction.followup.send( + embed=story.first_embed(), + file=story.first_attachments()[0], + view=story, + ) + await story.wait() + if story.last_interaction is None: + return + await story.last_interaction.response.defer(thinking=False) + + map_view = Map(interaction.user) + img = image_to_discord_file( + generate_map( + map_view.player.get_position(), + player_username=interaction.user.name, + player_display_name=interaction.user.display_name, + ), + image_name := "image", + ) + embed = discord.Embed( + title=f"\U0001f5fa {interaction.user.display_name}'s map", + description="Press the arrow keys to move around.", + color=discord.Color.blurple(), + ) + embed.set_image(url=f"attachment://{image_name}.png") + await interaction.edit_original_response( + attachments=[img], + embed=embed, + view=map_view, + ) + + +@bot.tree.command(name="level", description="Play a specific level without opening the map") +async def play_level(interaction: discord.Interaction, level: int) -> None: + """Play a specific level without opening the map.""" + chosen_level = Controller().get_level_by_id(level) + if chosen_level is None: + await interaction.response.send_message("Level not found.", ephemeral=True) + return + await chosen_level().run(interaction=interaction, map=Map(interaction.user)) + + +@bot.tree.command(name="eval", description="Evaluate Python code") +async def eval_code(interaction: discord.Interaction, *, code: str) -> None: + """Evaluate Python code and return the output.""" + await interaction.response.defer(ephemeral=True) + try: + output = await eval_python(code) + output = ( + ":white_check_mark: Your Python 3 eval job succeeded.\n\n**Output**\n```py\n" + output[:2000] + "\n```" + ) + except async_tio.ApiError as e: + print("Tio API error: ", e) + output = ":cross: An API error occured while running your code. Please try again later." + await interaction.followup.send( + embed=discord.Embed( + title="Eval result", + description=output, + color=discord.Color.blurple(), + ), + ephemeral=True, # To not drown the channel with eval, since the game map would be lost + ) + + +# Bot ready message +@bot.event +async def on_ready() -> None: + """Give a status report when the bot is ready.""" + print(f"Bot logged in as {bot.user}") + try: + synced = await bot.tree.sync() + print(f"{len(synced)} commands synchronized") + except discord.DiscordException as e: + print(f"Err: {e}") + + +# Load levels +register_all_levels() +print("Loaded levels:", ", ".join(str(level.id) for level in Controller().levels)) + + +# Start the bot +bot.run(getenv("DISCORD_BOT_KEY")) diff --git a/mesmerizing-lightyears/bot/map.py b/mesmerizing-lightyears/bot/map.py new file mode 100644 index 0000000..4d80985 --- /dev/null +++ b/mesmerizing-lightyears/bot/map.py @@ -0,0 +1,338 @@ +import io +import json +from pathlib import Path + +import discord +from config import Emoji +from controller import Controller +from database.models.player import PlayerRepo +from matplotlib import font_manager +from PIL import Image, ImageDraw, ImageFont +from utils.view import UserOnlyView + +path_bot = Path("bot") +path_assets = path_bot / "assets" +path_maps = path_assets / "map" +path_font = font_manager.findfont(font_manager.FontProperties(family="sans-serif", weight="normal")) + +CAMERA_H = 400 +CAMERA_W = 600 +SquareOrigo = (637, 1116.5) +SquareDeltaX = (111.3, -52) # Pixels travelled when moving X on map +SquareDeltaY = (111.3, 52) # Pixels travelled when moving Y on map +SquareDeltaZ = (0, -105) # Pixels travelled when moving Z on map + + +with Path.open(path_bot / "map_z.json") as f: + map_z = json.load(f) + + +def validate_coord(coord: tuple[int, int]) -> bool: + """Return whether or not the coordinate is not in the map.""" + return not (map_z.get(str(coord[0])) is None or map_z[str(coord[0])].get(str(coord[1])) is None) + + +class Map(UserOnlyView): + """Allows the user to navigate the map.""" + + def __init__(self, user: discord.User | discord.Member) -> None: + super().__init__(original_user=user) + self.player = PlayerRepo().get(user.name) + self.user = user + self.update_buttons() + + async def move( + self, + interaction: discord.Interaction, + x: int = 0, + y: int = 0, + ) -> None: + """Move the player to the new position.""" + old_x, old_y = self.player.get_position() + new_coord = old_x + x, old_y + y + if validate_coord(new_coord): + self.player.set_position(*new_coord) + PlayerRepo().save(self.player) + await self.navigate(interaction) + # If the new position is invalid, do nothing. Since the buttons are + # disabled if the resulting move would be invalid, this should only + # happen if the user somehow manages to click the button before it + # has a chance to be disabled. In that case, we just ignore the click. + + @staticmethod + def get_embed_description(position: tuple[int, int]) -> str: + """Get a descripton of the map at the given position.""" + level = Controller().get_level(position) + if level is None: + return "Press the arrow keys to move around." + + return f"## {level.name}: {level.topic}\nPress {Emoji.CHECK.value} to start the level." + + def update_buttons(self) -> None: + """Update the buttons to match the current position.""" + + def should_disable(x: int, y: int) -> bool: + return not validate_coord((x, y)) + + x, y = self.player.get_position() + for child in self.children: + if not isinstance(child, discord.ui.Button): + continue + if child.custom_id == "button_left": + child.disabled = should_disable(x - 1, y) + if child.custom_id == "button_up": + child.disabled = should_disable(x, y - 1) + if child.custom_id == "button_down": + child.disabled = should_disable(x, y + 1) + if child.custom_id == "button_right": + child.disabled = should_disable(x + 1, y) + if child.custom_id == "button_confirm": + child.disabled = not Controller().is_level(self.player.get_position()) + + async def navigate( + self, + interaction: discord.Interaction, + ) -> None: + """Update map to the new position.""" + await interaction.response.defer(thinking=False) + embed = discord.Embed( + title=f"\U0001f5fa {self.user.display_name}'s Map", + color=discord.Color.blurple(), + ) + embed.description = self.get_embed_description(self.player.get_position()) + img = image_to_discord_file( + generate_map( + self.player.get_position(), + player_username=interaction.user.name, + player_display_name=self.user.display_name, + ), + image_name := "image", + ) + embed.set_image(url=f"attachment://{image_name}.png") + self.update_buttons() + + await interaction.edit_original_response( + embed=embed, + attachments=[img], + view=self, + ) + + @discord.ui.button( + emoji=discord.PartialEmoji.from_str(Emoji.ARROW_LEFT.value), + style=discord.ButtonStyle.primary, + custom_id="button_left", + row=2, + ) + async def button_left_clicked( + self, + interaction: discord.Interaction, + _: discord.ui.Button, + ) -> None: + """Go left on the map.""" + await self.move(interaction, x=-1) + + @discord.ui.button( + emoji=discord.PartialEmoji.from_str(Emoji.ARROW_UP.value), + style=discord.ButtonStyle.primary, + custom_id="button_up", + ) + async def button_up_clicked( + self, + interaction: discord.Interaction, + _: discord.ui.Button, + ) -> None: + """Go up on the map.""" + await self.move(interaction, y=-1) + + @discord.ui.button( + emoji=discord.PartialEmoji.from_str(Emoji.ARROW_RIGHT.value), + style=discord.ButtonStyle.primary, + custom_id="button_right", + ) + async def button_right_clicked( + self, + interaction: discord.Interaction, + _: discord.ui.Button, + ) -> None: + """Go right on the map.""" + await self.move(interaction, x=1) + + @discord.ui.button( + emoji=discord.PartialEmoji.from_str(Emoji.ARROW_DOWN.value), + style=discord.ButtonStyle.primary, + custom_id="button_down", + row=2, + ) + async def button_down_clicked( + self, + interaction: discord.Interaction, + _: discord.ui.Button, + ) -> None: + """Go down on the map.""" + await self.move(interaction, y=1) + + @discord.ui.button( + emoji=discord.PartialEmoji.from_str(Emoji.CHECK.value), + style=discord.ButtonStyle.success, + custom_id="button_confirm", + disabled=True, + ) + async def confirm( + self, + interaction: discord.Interaction, + _: discord.ui.Button, + ) -> None: + """Confirm/select on the map.""" + level = Controller().get_level(self.player.get_position()) + if level is not None: + await level().run(interaction=interaction, map=self) + + +def get_camera_box( + position: tuple[int, int], + offset: tuple[int, int] = (0, 0), +) -> tuple[int, int, int, int]: + """Get the camera box for the map. + + offset is specified in pixels. + """ + x, y = position + z = map_z[str(x)][str(y)] + pos_x = round( + SquareOrigo[0] + x * SquareDeltaX[0] + y * SquareDeltaY[0] + z * SquareDeltaZ[0] + offset[0], + ) + pos_y = round( + SquareOrigo[1] + x * SquareDeltaX[1] + y * SquareDeltaY[1] + z * SquareDeltaZ[1] + offset[1], + ) + return ( + pos_x - round(CAMERA_W / 2), + pos_y - round(CAMERA_H / 2), + pos_x + round(CAMERA_W / 2), + pos_y + round(CAMERA_H / 2), + ) + + +def image_to_discord_file(image: Image.Image, file_name: str = "image") -> discord.File: + """Get a discord.File from a Pillow.Image.Image. Do not include extension in the file name.""" + with io.BytesIO() as image_binary: + image.save(image_binary, "PNG") + image_binary.seek(0) + return discord.File(fp=image_binary, filename=file_name + ".png") + + +def _crop_map( + position: tuple[int, int], + *, + map_name: str = "map-done-abc.png", + offset: tuple[int, int] = (0, 0), +) -> Image.Image: + """Crop the map so the camera centers on the given position, with the given offset. + + The function currently only supports the fully unlocked map. + """ + img = Image.open(path_maps / map_name) + box = get_camera_box(position, offset) + return img.crop(box) + + +def draw_player(position: tuple[int, int], map_name: str = "map-done-abc.png") -> tuple[Image.Image, int]: + """Draw the player on the map centered on the given position. + + Returns the map with the player on it and the player's height. + """ + player = Image.open(path_assets / "player.png").convert("RGBA") + player_w, player_h = player.size + offset = (0, round(-player_h / 2)) + bg = _crop_map(position, offset=offset, map_name=map_name).convert("RGBA") + bg.paste( + player, + ( + round(CAMERA_W / 2 - player_w / 2), + round(CAMERA_H / 2 - player_h / 2), + ), + player, + ) + return bg, player_h + + +def draw_name_box(bg: Image.Image, player_name: str, player_h: int) -> None: + """Draw a name box with the player's name on the map.""" + name_box = Image.open(path_assets / "name-box.png").convert("RGBA") + name_box_w, name_box_h = name_box.size + bg.paste( + name_box, + ( + round(CAMERA_W / 2 - name_box_w / 2), + round(CAMERA_H / 2 - player_h / 2 - 40), + ), + name_box, + ) + draw = ImageDraw.Draw(bg) + + fontsize = 24 + font = ImageFont.truetype(path_font, fontsize) + while font.getlength(player_name) > name_box_w - 10: + # Decrease font size until the text fits in the box + fontsize -= 1 + font = ImageFont.truetype(path_font, fontsize) + left, top, _, bottom = draw.textbbox( + ( + round(CAMERA_W / 2), + round(CAMERA_H / 2 - player_h / 2 - 40), + ), + player_name, + font=font, + align="center", + anchor="mm", + ) + draw.text( + (left, top + name_box_h / 2 - (bottom - top) / 2), + player_name, + font=font, + fill="black", + ) + + +def get_map_name(player_name: str) -> str: + """Get the file name for the map version that only has the player's levels unlocked.""" + player = PlayerRepo().get(player_name) + file_name = "map-done" if player.max_level == 11 else f"map-lvl{player.next_level}" # noqa: PLR2004, 11 is last level + + completed_levels = player.history.summary(completed=True) + all_levels = player.history.summary() + special = "" + if any(level["lvl_id"] == 12 for level in completed_levels): # noqa: PLR2004 + special += "a" + if any(level["lvl_id"] == 13 for level in all_levels): # noqa: PLR2004 + special += "b" + if any(level["lvl_id"] == 14 for level in all_levels): # noqa: PLR2004 + special += "c" + + return f"{file_name}{f'-{''.join(lvl for lvl in special)}' if special else ''}.png" + + +def generate_map( + position: tuple[int, int], + *, + player_username: str, + with_player: bool = True, + player_display_name: str | None = None, +) -> Image.Image: + """Generate a map centered on the provided map coordinate. + + The map coordinate is not in pixels but in the map's coordinate system. + Provide (x, y, z) coordinates to center the camera on the specified point. + + If with_player is True, the player will be added to the center of the camera. + The camera centers on the player centered and shifts the background image slightly, + so the player correctly stands on the point specified by MapPosition. + """ + if not with_player: + return _crop_map(position, map_name=get_map_name(player_username)) + bg, player_h = draw_player(position, map_name=get_map_name(player_username)) + + if player_display_name is None: + return bg + draw_name_box(bg, player_display_name, player_h) + + return bg diff --git a/mesmerizing-lightyears/bot/map_z.json b/mesmerizing-lightyears/bot/map_z.json new file mode 100644 index 0000000..e10516b --- /dev/null +++ b/mesmerizing-lightyears/bot/map_z.json @@ -0,0 +1,98 @@ +{ + "-2": { + "-3": 0, + "-2": 0, + "-1": 0, + "0": 0, + "1": 0 + }, + "-1": { + "0": 0, + "1": 0 + }, + "0": { + "0": 0, + "1": 0 + }, + "1": { + "0": 0, + "1": 0 + }, + "2": { + "-1": 0, + "0": 0, + "1": 0 + }, + "3": { + "-1": 0, + "0": 0, + "1": 0, + "6": -1 + }, + "4": { + "0": 0, + "1": 0, + "5": -1, + "6": -1, + "7": -1 + }, + "5": { + "-1": 0, + "0": 0, + "1": 0, + "6": -1, + "7": -1 + }, + "6": { + "0": 0, + "6": -1, + "7": -1 + }, + "7": { + "-2": 0, + "-1": 0, + "0": 0, + "1": 0, + "6": -1, + "7": -1 + }, + "8": { + "-2": 0, + "-1": 0, + "0": 0, + "1": 0, + "6": -1, + "7": -1 + }, + "9": { + "-2": 0, + "-1": 0, + "0": 0, + "1": 0, + "6": -1 + }, + "10": { + "-1": 0, + "0": 0, + "1": 0, + "6": -1 + }, + "11": { + "0": 0, + "1": 0, + "2": -0.5, + "3": -1, + "4": -1, + "5": -1, + "6": -1 + }, + "12": { + "1": 0, + "3": -1, + "4": -1, + "5": -1 + }, + "13": { + "4": -1 + } +} diff --git a/mesmerizing-lightyears/bot/questions.json b/mesmerizing-lightyears/bot/questions.json new file mode 100644 index 0000000..83c8bd3 --- /dev/null +++ b/mesmerizing-lightyears/bot/questions.json @@ -0,0 +1,591 @@ +{ + "1": [ + { + "type": "multiple_choice", + "question": "What will be the output of the following code?\n\n```python\nnums = [1, 2, 3, 4, 5]\ndoubled_nums = [num * 2 for num in nums]\nprint(doubled_nums)\n```\n", + "hints": [ + "The code uses a list comprehension to double each number in the list `nums`." + ], + "options": { + "a": "[1, 4, 9, 16, 25]", + "b": "[2, 4, 6, 8, 10]", + "c": "[2, 3, 4, 5, 6]" + }, + "answer": "b" + }, + { + "type": "write_code", + "question": "Write a list comprehension that squares all numbers in the list `numbers = [1, 2, 3, 4, 5]`.", + "hints": [ + "You can use the expression `x ** 2` to square a number `x`.", + "The list comprehension should iterate over each number in the list `numbers`.", + "The list comprehension should have the form `[expression for item in iterable]`.", + "The `expression` should square the `item`." + ], + "pre_code": "numbers=[1, 2, 3, 4, 5]", + "pre_submit_code": "squares=", + "test_cases": [ + { + "input": "squares", + "output": "[1, 4, 9, 16, 25]" + } + ] + }, + { + "type": "multiple_choice", + "question": "What will be the output of the following list comprehension?\n\n```python\nnumbers = [1, 2, 3, 4, 5]\nsquares = [x ** 2 for x in numbers if x % 2 == 0]\nprint(squares)\n```\n", + "hints": [ + "The list comprehension squares each number in the list `numbers` if the number is even.", + "The condition `if x % 2 == 0` checks if the number `x` is even, by checking if it's divisible by 2." + ], + "options": { + "a": "[1, 4, 9, 16, 25]", + "b": "[1, 9, 25]", + "c": "[4, 16]" + }, + "answer": "c" + } + ], + "2": [ + { + "type": "write_code", + "question": "Write a generator function `even_numbers` that generates even numbers starting from 2 up to a given limit `n` (including `n`, if it's even).", + "hints": [ + "You can use a `while` loop to generate even numbers starting from 2.", + "The generator function should `yield` each even number.", + "The generator function should stop when the next even number is greater than `n`." + ], + "test_cases": [ + { + "input": "list(even_numbers(5))", + "output": "[2, 4]" + }, + { + "input": "list(even_numbers(10))", + "output": "[2, 4, 6, 8, 10]" + } + ] + }, + { + "type": "multiple_choice", + "question": "What will be the output of the following code snippet?\n\n```python\ndef fibonacci_generator():\n a, b = 0, 1\n while True:\n yield a\n a, b = b, a + b\n\nfib_gen = fibonacci_generator()\nfib_sequence = [next(fib_gen) for _ in range(5)]\nprint(fib_sequence)\n```\n", + "hints": [ + "The code snippet defines a generator function `fibonacci_generator` that generates Fibonacci numbers.", + "The list comprehension `[next(fib_gen) for _ in range(5)]` generates the first 5 Fibonacci numbers using the generator." + ], + "options": { + "a": "[0, 1, 1, 2, 3]", + "b": "[1, 1, 2, 3, 5]", + "c": "[0, 1, 2, 3, 4]" + }, + "answer": "a" + } + ], + "3": [ + { + "type": "multiple_choice", + "question": "What is the purpose of an iterator in Python?", + "hints":[ + "An iterator is an object that can be iterated over using a `for` loop or other looping constructs." + ], + "options": { + "a": "To generate random numbers.", + "b": "To iterate over items in a sequence or collection.", + "c": "To create new objects from existing ones." + }, + "answer": "b" + }, + { + "type": "multiple_choice", + "question": "Consider the following iterator implementation:\n\n```python\nclass Countdown:\n def __init__(self, start):\n self.start = start\n\n def __iter__(self):\n return self\n\n def __next__(self):\n if self.start <= 0:\n raise StopIteration\n else:\n self.start -= 1\n return self.start + 1\n\ncountdown = Countdown(3)\nprint(list(countdown))\n```\n\nWhat will be the output of the code snippet?", + "hints": [ + "The `Countdown` class defines an iterator that counts down from a given start number to 1.", + "The `__iter__` method returns the iterator object itself, and the `__next__` method generates the next value in the sequence." + ], + "options": { + "a": "[3, 2, 1]", + "b": "[1, 2, 3]", + "c": "[0, 1, 2]" + }, + "answer": "a" + }, + { + "type": "write_code", + "question": "Write an iterator class `Countdown` that iterates from a given start number down to 1.", + "hints": [ + "The iterator should start from the given `start` number and count down to 1.", + "Remember to decrement the countdown value after each iteration.", + "The `__iter__` method should return the iterator object itself.", + "The `__next__` method should return the next value in the countdown sequence." + ], + "test_cases":[ + { + "input": "list(Countdown(3))", + "output": "[3, 2, 1]" + }, + { + "input": "list(Countdown(0))", + "output": "[]" + }, + { + "input": "next(Countdown(5))", + "output": "5" + } + ] + } + ], + "4": [ + { + "type": "multiple_choice", + "question": "Does Python support function overloading by default (like in languages such as C++)?", + "hints":[ + "Function overloading refers to the ability to define multiple functions with the same name but different parameters." + ], + "options": { + "a": "Yes", + "b": "No", + "c": "It depends on the version of Python used" + }, + "answer": "b" + }, + { + "type": "write_code", + "question": "Implement a function `multiply` that can take either two or three arguments. If two arguments are provided, it should return their product. If three arguments are provided, it should return the product of all three numbers.", + "hints": [ + "You can use default argument values to handle the case where the third argument is not provided.", + "The function should return the product of the first two arguments plus the third argument, if it exists." + ], + "test_cases": [ + { + "input": "multiply(2, 3)", + "output": "6" + }, + { + "input": "multiply(2, 3, 4)", + "output": "24" + }, + { + "input": "multiply(-2, 5)", + "output": "-10" + }, + { + "input": "multiply(-2, 5, 3)", + "output": "-30" + } + ] + }, + { + "type": "multiple_choice", + "question": "Consider the following Python code:\n\n```python\ndef add(a, b):\n return a + b\n\ndef add(a, b, c):\n return a + b + c\n\nprint(add(1, 2))\n```\n\nWhat will be the output of this code?", + "hints": [ + "The code defines two functions with the same name `add` but different numbers of arguments.", + "Python does not support function overloading by default, so the second function definition will overwrite the first." + ], + "options": { + "a": "3", + "b": "TypeError: add() missing 1 required positional argument: 'c'", + "c": "TypeError: add() takes 3 positional arguments but 2 were given" + }, + "answer": "b" + } + ], + "5": [ + { + "type": "multiple_choice", + "question": "What will be the output of the following code?\n\n```python\ndef overloaded_function(x, y=None):\n if y is None:\n return (i * 2 for i in x)\n else:\n return [i * 2 for i in range(x, y)]\n\nresult1 = overloaded_function([1, 2, 3])\nresult2 = overloaded_function(3, 6)\n\nprint(list(result1))\nprint(result2)\n```\n", + "hints": [ + "The function uses a generator when only one argument is passed and a list comprehension when two arguments are passed.", + "The generator yields elements one by one, while the list comprehension returns a complete list." + ], + "options": { + "a": "[2, 4, 6] [6, 8, 10, 12]", + "b": "[2, 4, 6] [6, 8, 10]", + "c": "[2, 4, 6, 8, 10] [6, 8, 10]" + }, + "answer": "b" + }, + { + "type": "write_code", + "question": "Write a regular expression that matches an email address. Compile the regex and store it in the variable `pattern`.\n\n*Assume that the email has the format `name@domain.com` and consists of only ascii letters.*", + "hints": [ + "Remember to `import re` to use the regex module.", + "Use `re.compile` to compile your regex." + ], + "test_cases": [ + { + "input": "bool(pattern.match('test@example.com'))", + "output": "True" + }, + { + "input": "bool(pattern.match('not-an-email'))", + "output": "False" + } + ] + }, + { + "type": "multiple_choice", + "question": "What will be the output of the following code?\n\n```python\nimport re\npattern = re.compile(r'\\b\\w+\\b')\nresult = pattern.findall('This is a test')\nprint(result)\n```\n", + "hints": [ + "The pattern '\\b\\w+\\b' matches whole words." + ], + "options": { + "a": "['This', 'is', 'a', 'test', '']", + "b": "['This', 'is', 'a', 'test']", + "c": "['T', 'h', 'i', 's', 'i', 's', 'a', 't', 'e', 's', 't']" + }, + "answer": "b" + } + ], + "6": [ + { + "type": "multiple_choice", + "question": "What will be the output of the following code?\n\n```python\nwith open('example.txt', 'w') as file:\n file.write('Hello, World!')\n\nwith open('example.txt', 'r') as file:\n content = file.read()\nprint(content)\n```\n", + "hints": [ + "The code writes 'Hello, World!' to a file named 'example.txt' and then reads the content of the file." + ], + "options": { + "a": "FileNotFoundError", + "b": "Hello, World!", + "c": "" + }, + "answer": "b" + }, + { + "type": "multiple_choice", + "question": "Which mode should you use to open a file for both reading and writing?\n", + "hints": [ + "There is a mode that allows for both reading and writing in Python." + ], + "options": { + "a": "w+", + "b": "rw", + "c": "r+" + }, + "answer": "c" + }, + { + "type": "multiple_choice", + "question": "What will be the output of the following code?\n\n```python\nimport os\n\nclass FileManager:\n def __init__(self, filename):\n self.filename = filename\n def process_file(self):\n with open(self.filename, 'w') as f:\n f.write('Line1\\nLine2\\nLine3')\n with open(self.filename, 'r') as f:\n lines = f.readlines()\n return [line.strip() for line in lines if line.strip().startswith('Line')]\n\nmanager = FileManager('test_file.txt')\nprint(manager.process_file())\nos.remove('test_file.txt')\n```\n", + "hints": [ + "The `process_file` method writes to the file, then reads from it and processes the lines.", + "The method uses `strip` and `startswith` to filter lines." + ], + "options": { + "a": "['Line1', 'Line2']", + "b": "['Line1', 'Line2', 'Line3']", + "c": "['Line2', 'Line3']" + }, + "answer": "b" + } + ], + "7": [ + { + "type": "multiple_choice", + "question": "What does the following regular expression match?\n\n```python\nimport re\npattern = re.compile(r'\\d+')\nresult = pattern.match('abc123')\n```\n", + "hints": [ + "The pattern '\\d+' matches one or more digits." + ], + "options": { + "a": "123", + "b": "abc", + "c": "None" + }, + "answer": "c" + }, + { + "type": "write_code", + "question": "Write a regular expression that matches an email address and test it using the re module.", + "hints": [ + "Use the re module to compile and match the regular expression." + ], + "test_cases": [ + { + "input": "bool(pattern.match('test@example.com'))", + "output": "True" + }, + { + "input": "bool(pattern.match('not-an-email'))", + "output": "False" + } + ] + }, + { + "type": "multiple_choice", + "question": "What will be the output of the following code?\n\n```python\nimport re\npattern = re.compile(r'\\b\\w+\\b')\nresult = pattern.findall('This is a test')\nprint(result)\n```\n", + "hints": [ + "The pattern '\\b\\w+\\b' matches whole words." + ], + "options": { + "a": "['This', 'is', 'a', 'test', '']", + "b": "['This', 'is', 'a', 'test']", + "c": "['T', 'h', 'i', 's', 'i', 's', 'a', 't', 'e', 's', 't']" + }, + "answer": "b" + } + ], + "8": [ + { + "type": "multiple_choice", + "question": "What will be the output of the following code?\n\n```python\nclass Dog:\n def __init__(self, name):\n self.name = name\n def bark(self):\n return 'Woof!'\n\nmy_dog = Dog('Fido')\nprint(my_dog.bark())\n```\n", + "hints": [ + "The bark method returns the string 'Woof!'." + ], + "options": { + "a": "Woof!", + "b": "Fido", + "c": "None" + }, + "answer": "a" + }, + { + "type": "write_code", + "question": "Write a Python class named `Car` with an `__init__` method that initializes the make and model of the car, and a method named `description` that returns a string in the format `'Make: [make], Model: [model]'`.", + "hints": [ + "Define the `__init__` method to accept make and model as parameters: `__init__('Toyota', 'Corolla')` should be valid.", + "The description method should return a formatted string using the instance variables.", + "Do not include the `[` and `]` characters in your output", + "Check that the spacing in your output is exactly correct!" + ], + "test_cases": [ + { + "input": "Car('Toyota', 'Corolla').description()", + "output": "'Make: Toyota, Model: Corolla'" + }, + { + "input": "Car('Honda', 'Civic').description()", + "output": "'Make: Honda, Model: Civic'" + } + ] + }, + { + "type": "multiple_choice", + "question": "What will be the output of the following code?\n\n```python\nclass Animal:\n def sound(self):\n pass\n\nclass Cat(Animal):\n def sound(self):\n return 'Meow'\n\nmy_cat = Cat()\nprint(my_cat.sound())\n```\n", + "hints": [ + "The Cat class overrides the sound method to return 'Meow'." + ], + "options": { + "a": "Woof", + "b": "None", + "c": "Meow" + }, + "answer": "c" + } + ], + "9": [ + { + "type": "multiple_choice", + "question": "Which of the following best describes polymorphism in object-oriented programming?", + "hints": [ + "Polymorphism allows methods to do different things based on the object it is acting upon." + ], + "options": { + "a": "Polymorphism allows objects to take on many forms", + "b": "Polymorphism allows the hiding of data from direct access", + "c": "Polymorphism is the ability to create a new class from an existing class" + }, + "answer": "a" + }, + { + "type": "multiple_choice", + "question": "Which of the following is an example of encapsulation in Python?", + "hints": [ + "Encapsulation involves bundling the data and methods that operate on the data within one unit, like a class." + ], + "options": { + "a": "Using inheritance to create a subclass", + "b": "Using a for loop to iterate over a list", + "c": "Using getter and setter methods to access private variables" + }, + "answer": "c" + } + ], + "10": [ + { + "type": "multiple_choice", + "question": "Consider the following Python code snippet:\n\n```python\nimport re\n\nclass LogAnalyzer:\n def __init__(self, file_path):\n self._file_path = file_path\n self._lines = self._read_file()\n\n def _read_file(self):\n with open(self._file_path, 'r') as file:\n return file.readlines()\n\n def get_error_lines(self):\n return [line for line in self._lines if re.search(r'ERROR', line)]\n\nanalyzer = LogAnalyzer('log.txt')\nprint(analyzer.get_error_lines())\n```\n\nWhat will be the output if 'log.txt' contains lines with the word 'ERROR'?", + "hints": [ + "The class encapsulates file handling and uses a regular expression to find lines with 'ERROR'.", + "The _read_file method reads the file contents into a list of lines.", + "The get_error_lines method uses a list comprehension to filter lines containing 'ERROR'." + ], + "options": { + "a": "A list of lines containing the word 'ERROR'", + "b": "All lines in the file", + "c": "None" + }, + "answer": "a" + }, + { + "type": "multiple_choice", + "question": "Which method demonstrates encapsulation in the following code snippet?\n\n```python\nclass TextProcessor:\n def __init__(self, text):\n self._text = text\n\n def get_uppercase_words(self):\n return [word for word in self._text.split() if word.isupper()]\n\nprocessor = TextProcessor('HELLO world PYTHON')\nprint(processor.get_uppercase_words())\n```\n", + "hints": [ + "Encapsulation involves hiding the internal state of the object.", + "The get_uppercase_words method operates on the encapsulated _text attribute." + ], + "options": { + "a": "__init__ method", + "b": "get_uppercase_words method", + "c": "split method" + }, + "answer": "b" + }, + { + "type": "multiple_choice", + "question": "The file `data.txt` contains the following text: ```\n123 ABC\n456 DEF\n789 GHI\n```\n\nWhat will the output from this code be? ```python\nclass DataFilter:\n def __init__(self, file_path):\n self._file_path = file_path\n\n def filter_numeric_lines(self):\n with open(self._file_path, 'r') as file:\n return [line for line in file if re.match(r'\\d+', line)]\n\nfilter = DataFilter('data.txt')\nprint(filter.filter_numeric_lines())\n```", + "hints": [ + "The `filter_numeric_lines` method reads the file and uses a regular expression to filter lines starting with digits.", + "List comprehension is used to create the list of matching lines." + ], + "options": { + "a": "['123 ABC\\n', '456 DEF\\n', '789 GHI\\n']", + "b": "['123 ABC\\n', '456 DEF\\n', '789 GHI']", + "c": "['123 ABC', '456 DEF', '789 GHI']" + }, + "answer": "b" + }, + { + "type": "multiple_choice", + "question": "The file `data.txt` contains the following text: ```one1\ntwo2\nthree3\n```\nWhat will be the output of the following code? ```python\nimport re\n\nclass WordExtractor:\n def __init__(self, file_path):\n self._file_path = file_path\n\n def extract_words(self):\n with open(self._file_path, 'r') as file:\n return [re.sub(r'\\d+', '', line.strip()) for line in file]\n\nextractor = WordExtractor('input.txt')\nprint(extractor.extract_words())\n```", + "hints": [ + "The extract_words method uses a regular expression to remove digits from each line.", + "List comprehension is used to create a list of cleaned lines." + ], + "options": { + "a": "['one', 'two', 'three']", + "b": "['one1', 'two2', 'three3']", + "c": "['one\\n', 'two\\n', 'three\\n']" + }, + "answer": "a" + } + ], + "11": [ + { + "type": "multiple_choice", + "question": "What will be the output of the following code?\n\n```python\nclass Base:\n def __init__(self, value):\n self.value = value\n def display(self):\n return f'Base: {self.value}'\n\nclass Derived(Base):\n def display(self):\n return f'Derived: {self.value}'\n\nobj = Derived(10)\nprint(obj.display())\n```\n", + "hints": [ + "The Derived class overrides the display method of the Base class." + ], + "options": { + "a": "Base: 10", + "b": "Derived: 10", + "c": "Error" + }, + "answer": "b" + }, + { + "type": "multiple_choice", + "question": "What is the output of the following code?\n\n```python\nclass Overloaded:\n def func(self, a, b=None):\n if b is None:\n return a * 2\n return a + b\n\nobj = Overloaded()\nprint(obj.func(3))\nprint(obj.func(3, 4))\n```\n", + "hints": [ + "The method func is overloaded to handle cases with one or two arguments." + ], + "options": { + "a": "6\n12", + "b": "Error", + "c": "6\n7" + }, + "answer": "c" + }, + { + "type": "multiple_choice", + "question": "What is the output of the following code?\n\n```python\ndef generator():\n for i in range(3):\n yield i * 2\n\nclass IterableGen:\n def __iter__(self):\n return generator()\n\nobj = IterableGen()\nresult = list(obj)\nprint(result)\n```\n", + "hints": [ + "The IterableGen class makes use of a generator function to create an iterable." + ], + "options": { + "a": "[0, 1, 2]", + "b": "[0, 2, 4]", + "c": "Error" + }, + "answer": "b" + }, + { + "type": "write_code", + "question": "Write a Python class `Squares` that implements the `__iter__` and `__next__` methods to generate squares of numbers starting from 1 up to a given limit `n` (including `n`).", + "hints": [ + "Save the value of `n` when initializing the iterator.", + "The `__iter__` method should return the iterator object itself.", + "The `__next__` method should return the next square value in the sequence.", + "The iterator should stop when the next square value is greater than `n`." + ], + "test_cases": [ + { + "input": "list(Squares(5))", + "output": "[1, 4, 9, 16, 25]" + }, + { + "input": "list(Squares(10))", + "output": "[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]" + }, + { + "input": "next(Squares(3))", + "output": "1" + } + ] + } + ], + "12": [ + { + "type": "write_golf_code", + "question": "Golf the following code to return the sum of squares of a list of integers.\n```python\ndef sum_of_squares(nums):\n result = 0\n for num in nums:\n result += num ** 2\n return result\n```", + "hints": [ + "Remove any spaces that aren't strictly necessary.", + "Use built-in functions to minimize code length.", + "Use a single line list comprehension.", + "Remember -- it only has to work. It doesn't have to be readable!" + ], + "test_cases": [ + { + "input": "sum_of_squares([1, 2, 3, 4])", + "output": "30" + }, + { + "input": "sum_of_squares([0, 5, 10])", + "output": "125" + } + ], + "max_characters": 44 + } + ], + "13": [ + { + "type": "write_golf_code", + "question": "Golf the following code to merge two sorted lists of ints into a single sorted list.\n\n```python\ndef merge_sorted_lists(list1, list2):\n result = []\n i = j = 0\n while i < len(list1) and j < len(list2):\n if list1[i] < list2[j]:\n result.append(list1[i])\n i += 1\n else:\n result.append(list2[j])\n j += 1\n result.extend(list1[i:])\n result.extend(list2[j:])\n return result\n```", + "hints": [ + "Consider using the sorted function.", + "Combine the two lists first." + ], + "test_cases": [ + { + "input": "merge_sorted_lists([1, 3, 5], [2, 4, 6])", + "output": "[1, 2, 3, 4, 5, 6]" + }, + { + "input": "merge_sorted_lists([0, 10, 20], [5, 15, 25])", + "output": "[0, 5, 10, 15, 20, 25]" + } + ], + "max_characters": 41 + } + ], + "14": [ + { + "type": "write_golf_code", + "question": "Golf the following code to find all prime numbers up to a given number n.\n\n```python\ndef find_primes(n):\n primes = []\n for num in range(2, n + 1):\n is_prime = True\n for i in range(2, int(num ** 0.5) + 1):\n if num % i == 0:\n is_prime = False\n break\n if is_prime:\n primes.append(num)\n return primes\n```", + "hints": [ + "Use list comprehensions to condense the code.", + "Consider using the all() function." + ], + "test_cases": [ + { + "input": "find_primes(10)", + "output": "[2, 3, 5, 7]" + }, + { + "input": "find_primes(20)", + "output": "[2, 3, 5, 7, 11, 13, 17, 19]" + } + ], + "max_characters": 89 + } + ] +} diff --git a/mesmerizing-lightyears/bot/questions.py b/mesmerizing-lightyears/bot/questions.py new file mode 100644 index 0000000..aca4d73 --- /dev/null +++ b/mesmerizing-lightyears/bot/questions.py @@ -0,0 +1,474 @@ +import re +import typing +from abc import abstractmethod +from enum import Enum, auto +from typing import Protocol + +import discord +from config import Emoji +from discord import Embed, Interaction, TextStyle +from utils.eval import eval_python +from utils.view import UserOnlyView + +if typing.TYPE_CHECKING: + from levels import Level + +check_test = re.compile(r"Ran 1 test in \S+s\n\nOK\n$") # Successful unit tests output should end with this + + +class Question(Protocol): + """Protocol that all questions must implement. + + Different question types can be created by subclassing this protocol. + + The general structure of a question's JSON data is as follows: + { + "question": "What is 2 + 2?", + "hints": [ + "Remember the order of operations." + ... + ], + "type": "multiple_choice", + } + + The `hints` field is a list of strings that provide hints to the user. If the hints are requested + by the user, they will be shown in the order that they are listed in the JSON data. + + The `question` field is a string that contains the question text. + + The `type` field determines the type of question. The following types are currently supported: + - "multiple_choice": The user selects the correct answer from multiple choices + - "write_code": The user writes Python code to solve a problem + + Depending on what the `type` field is set to, there are additional fields that must be included in the JSON data. + The additional fields can be found in the docstrings of the specific Question subclasses. + """ + + type: str + question: str + hints: list[str] + unlocked_hints: int # Indexes of unlocked hints + + def __str__(self) -> str: + """Return the question text.""" + return self.question + + @abstractmethod + async def check_response(self, response: str) -> bool: + """Check if the answer is correct.""" + raise NotImplementedError + + def get_embed_description(self, question_index: int) -> str: + """Return the description for the embed message.""" + return f"## Question {question_index}\n{self.question}" + + def embed(self, level: "Level", question_index: int) -> Embed: + """Return an embed message for the question.""" + embed = Embed( + title=f"{level.name}: {level.topic}", + description=self.get_embed_description(question_index), + color=discord.Color.blurple(), + ) + embed.set_thumbnail( + url="attachment://hearts.png", + ) # Must be exactly hearts.png. Level.get_hearts_file depends on it + return embed + + def view(self, user: discord.User | discord.Member) -> "QuestionView": + """Return the view for the question.""" + if self.type == "multiple_choice": + return MultipleChoiceQuestionView(self, user) # type: ignore # noqa: PGH003 + if self.type in ["write_code", "write_golf_code"]: + return WriteCodeQuestionView(self, user) # type: ignore # noqa: PGH003 + raise ValueError + + +class QuestionStatus(Enum): + """Status of the question interaction.""" + + IN_PROGRESS = auto() + CORRECT = auto() + INCORRECT = auto() + EXITED = auto() + + +class QuestionView(UserOnlyView): + """View for a question. + + The view contains buttons for the user to interact with the question. + """ + + def __init__(self, question: Question, user: discord.User | discord.Member) -> None: + super().__init__(original_user=user) + self.question = question + self.next_question_interaction: Interaction | None = None + self.status: QuestionStatus = QuestionStatus.IN_PROGRESS + + @discord.ui.button( + emoji=discord.PartialEmoji.from_str(Emoji.HINT.value), + label="Hint", + row=2, + style=discord.ButtonStyle.secondary, + ) + async def get_a_hint(self, interaction: discord.Interaction, _: discord.ui.Button) -> None: + """Reveal one more hint.""" + if self.question.unlocked_hints < len(self.question.hints): + self.question.unlocked_hints += 1 + hints = "- " + "\n- ".join(self.question.hints[: self.question.unlocked_hints]) + await interaction.response.send_message( + content=f"### Hints ({self.question.unlocked_hints}/{len(self.question.hints)})\n" + hints, + ephemeral=True, + ) + + @discord.ui.button( + label="Quit level", + emoji=discord.PartialEmoji.from_str(Emoji.CROSS.value), + style=discord.ButtonStyle.secondary, + row=2, + ) + async def quit_button(self, interaction: discord.Interaction, _: discord.ui.Button) -> None: + """Quit the question.""" + await interaction.response.defer() + await self.on_quit(interaction) + + async def on_quit(self, interaction: discord.Interaction) -> None: + """Execute when the user quits the question.""" + self.status = QuestionStatus.EXITED + self.next_question_interaction = interaction + self.stop() + + async def on_success(self, interaction: discord.Interaction) -> None: + """Execute when the user answers the question correctly.""" + self.status = QuestionStatus.CORRECT + self.next_question_interaction = interaction + self.stop() + + async def on_fail(self, interaction: discord.Interaction) -> None: + """Execute when the user answers the question incorrectly.""" + self.status = QuestionStatus.INCORRECT + self.next_question_interaction = interaction + self.stop() + + +class MultipleChoiceQuestion(Question): + """A type of Level where the user selects the correct answer from multiple choices. + + In the JSON data for a multiple choice question, the following additional fields must be included: + { + "options": { + "a": "4", + "b": "5", + "c": "6", + }, + "answer": "a", + } + + The `options` field is a dictionary where the keys are the options and the values are the value of the option. + The keys should be single lowercase letters, starting from "A" and increasing alphabetically and serves as the + ID for the option. + + The `answer` field is a string that contains the ID of the correct answer. The ID should be one of the keys in the + `options` dictionary. + """ + + def __init__(self, question: str, hints: list[str], options: dict[str, str], answer: str) -> None: + self.type = "multiple_choice" + self.question = question + self.hints = hints + self.unlocked_hints = 0 + self.options = options + self.answer = answer + + async def check_response(self, response: str) -> bool: # noqa: D102 + return response == self.answer + + +class MultipleChoiceQuestionView(QuestionView): + """View for a multiple choice question. + + The view contains buttons for the user to select the answer. + """ + + def __init__(self, question: MultipleChoiceQuestion, user: discord.User | discord.Member) -> None: + super().__init__(question, user) + self.question = question + for option_id, label in question.options.items(): + self.add_option_button(option_id, label) + + @staticmethod + def _emoji(option_id: str) -> discord.PartialEmoji: + ids = { + "a": Emoji.LETTER_A, + "b": Emoji.LETTER_B, + "c": Emoji.LETTER_C, + "d": Emoji.LETTER_D, + "e": Emoji.LETTER_E, + "f": Emoji.LETTER_F, + } + return discord.PartialEmoji.from_str( + ids[option_id].value, + ) + + def add_option_button(self, option_id: str, label: str) -> None: + """Add a button for an option.""" + + async def callback(interaction: discord.Interaction) -> None: + """Button callback.""" + await interaction.response.defer() + if await self.question.check_response(option_id): + await self.on_success(interaction) + else: + await self.on_fail(interaction) + + button = discord.ui.Button( + emoji=self._emoji(option_id), + label=label, + style=discord.ButtonStyle.primary, + ) + button.callback = callback + self.add_item(button) + + +class WriteCodeQuestion(Question): + """A type of Level where the user writes code to solve a problem. + + In the JSON data for a write code question, the following additional fields must be included: + { + "pre_code": "squares = ", # Assign user's one-liner to variable squares + "test_cases": [ + ["add(1, 2)", "3"], + ["add(-1, 3)", "2"], + ], + } + + The `test_cases` field is a list of tuples where the first element is the input to a unit test and the second + is the expected output. The input and output should be strings that can be evaluated as Python code. For example, + if the expected value is the string literal 'hello', it should be enclosed in quotes: "'hello'". + + The `pre_code` code will be insert right before the user's code. No newline will be inserted between it and the + user's code, which enables assigning the user's code output to a variable. If you do want to separate the pre_code + from the user's code, please end pre_code with a line break (\\n). For example: `"pre_code": "import inspect\\n"`. + `pre_code` is an optional field. + + The question should clearly state what the expected name of the function to be created is. For example, if the + question is to create a function that adds two numbers, the question should state that the function should be + named `add`. If the function is not named correctly, the tests will fail. + """ + + def __init__( + self, + question: str, + hints: list[str], + test_cases: list[dict[str, str]], + pre_code: str | None = None, + pre_submit_code: str | None = None, + ) -> None: + self.type = "write_code" + self.question = question + self.hints = hints + self.pre_code = pre_code + self.unlocked_hints = 0 + self.pre_submit_code = pre_submit_code + self.test_cases = test_cases + + async def check_response(self, code: str) -> bool: + """Check if the code answer is correct. + + Runs a suite of unit tests on the code. If they all pass, the code is deemed correct. + + Raises an error if a connection to the code evaluation service fails. + """ + test_string = self._get_test_string(code) + output = await eval_python(test_string) + return bool(check_test.search(output)) # If unit tests pass, the code is correct + + @staticmethod + def _get_assert_equal_string(input: str, output: str) -> str: + """Return a code string that asserts if the input and output are equal. + + Input and output strings will be parsed and interpreted as raw Python code. + For example, if a value is supposed to be a string, it should be enclosed in quotes. + + Example: + ------- + >>> get_assert_equal_string("uwuify('hello')", "'hewwo'") # Include quotes around "hewwo" + "self.assetEqual(uwuify('hello'), 'hewwo')" + + """ + return f"self.assertEqual({input},{output})" + + def _get_test_string(self, user_code: str) -> str: + """Return the test string to be used in the code evaluation service. + + This is not an optimal way of testing code, but it is fairly straightforward and easy to implement for now. + It can cause bugs if the input and output strings are not formatted correctly, which might not be trivial + to debug. However, since the code is running in a sandboxed environment, this method of generating tests + poses no security risk. + """ + test_strings = [] + for test_case in self.test_cases: + input = test_case["input"] + output = test_case["output"] + test_strings.append(self._get_assert_equal_string(input, output)) + return ( + "import unittest\n" # Import unittest module + + ("" if self.pre_code is None else self.pre_code + "\n") # Adds code to run before user code + + ( + "" if self.pre_submit_code is None else self.pre_submit_code + ) # Code to collect user output from one-liner, without a line break after it + + user_code.expandtabs(2) # Insert user code. Tabs -> spaces for consistency with test code + + "\nclass Test(unittest.TestCase):\n" # Setup test class + + " def test_cases(self):" # Setup test method + + "\n " # Indentation for first test case + + "\n ".join(test_strings) # Add assertions for test cases + + "\nunittest.main()" # Run unit tests + ) + + def get_embed_description(self, question_index: int) -> str: # noqa: D102 + return ( + super().get_embed_description(question_index) + + "\n\n*Tip: press the Code Playground button to try out your code before submitting!*" + + " :warning: *But there and **ONLY THERE** you will need to use `print(...)` to see the result*" + ) + + +class WriteGolfCodeQuestion(WriteCodeQuestion): + """A type of Level where the user writes code to solve a problem in the fewest characters possible.""" + + def __init__( # noqa: PLR0913, RUF100 + self, + question: str, + hints: list[str], + test_cases: list[dict[str, str]], + max_characters: int, + pre_code: str | None = None, + pre_submit_code: str | None = None, + ) -> None: + self.type = "write_code" + self.question = question + self.hints = hints + self.pre_submit_code = pre_submit_code + self.unlocked_hints = 0 + self.pre_code = pre_code + self.max_characters = max_characters + self.test_cases = test_cases + + async def check_response(self, code: str) -> bool: + """Check if the code answer is correct. + + Runs a suite of unit tests on the code. If they all pass, the code is deemed correct. + + Raises an error if a connection to the code evaluation service fails. + """ + test_string = self._get_test_string(code) + output = await eval_python(test_string) + passes_tests = bool(check_test.search(output)) + return passes_tests and len(code) <= self.max_characters + + def get_embed_description(self, question_index: int) -> str: # noqa: D102 + return ( + super(WriteCodeQuestion, self).get_embed_description(question_index) + + f"\n### Maximum characters allowed: {self.max_characters}\n" + + "\n*Tip: press the Code Playground button to try out your code before submitting!*" + ) + + +class CodeModal(discord.ui.Modal, title="Submit code"): + """Modal for submitting code.""" + + def __init__(self, *args, view: "WriteCodeQuestionView", title: str | None = None, **kwargs) -> None: # noqa: ANN002, ANN003 + super().__init__(*args, **kwargs) + self.view = view + self.submit_interaction = None + if title is not None: + self.title = title + + code_input = discord.ui.TextInput( + label="Python Code", + placeholder="Write your code here", + style=TextStyle.long, + min_length=1, + max_length=2000, + ) + + async def on_submit(self, interaction: discord.Interaction) -> None: + """Submit the code.""" + self.submit_interaction = interaction + self.stop() + + +class WriteCodeQuestionView(QuestionView): + """View for a write code question. + + The view contains a text box for the user to write code. + """ + + def __init__(self, question: WriteCodeQuestion, user: discord.User | discord.Member) -> None: + super().__init__(question, user) + self.question = question + self.post_modal_interaction: Interaction + + @discord.ui.button( + label="Enter Python code", + style=discord.ButtonStyle.primary, + ) + async def submit_button(self, interaction: discord.Interaction, _: discord.ui.Button) -> None: + """Open modal and get text response.""" + modal = CodeModal(view=self) + await interaction.response.send_modal(modal) + await modal.wait() + if modal.submit_interaction is None: + self.status = QuestionStatus.EXITED + print("Unable to get interaction, exiting question.") + self.stop() + return + await modal.submit_interaction.response.defer() + if await self.question.check_response(modal.code_input.value): + await self.on_success(modal.submit_interaction) + else: + await self.on_fail(modal.submit_interaction) + + @discord.ui.button( + label="Code Playground", + style=discord.ButtonStyle.primary, + ) + async def playground_button(self, interaction: discord.Interaction, _: discord.ui.Button) -> None: + """Open code playground modal.""" + modal = CodeModal(view=self, title="Test you code") + await interaction.response.send_modal(modal) + await modal.wait() + if modal.submit_interaction is None: + await interaction.response.send_message( + content="Unable to run code right now. Please try again later.", + ephemeral=True, + ) + return + + await modal.submit_interaction.response.defer(thinking=True, ephemeral=True) + code_input = (self.question.pre_code or "") + "\n" + modal.code_input.value + output = await eval_python(code_input) + output = output[:1900] if len(output) > 0 and output != "\n" else "No output" + embed = Embed( + title="Code Playground Results", + description=f"**Input:**\n```py\n{code_input}\n```\n**Output:**\n```py\n{output}\n```", + color=discord.Color.blurple(), + ) + await modal.submit_interaction.followup.send( + embed=embed, + ephemeral=True, + ) + + +def question_factory(**question_data) -> Question: # noqa: ANN003 + """Create Question instance using the correct Question subclass.""" + question_type = question_data.get("type") + question_data.pop("type") + match question_type: + case "multiple_choice": + return MultipleChoiceQuestion(**question_data) + case "write_code": + return WriteCodeQuestion(**question_data) + case "write_golf_code": + return WriteGolfCodeQuestion(**question_data) + case _: + raise ValueError diff --git a/mesmerizing-lightyears/bot/story.py b/mesmerizing-lightyears/bot/story.py new file mode 100644 index 0000000..2db0507 --- /dev/null +++ b/mesmerizing-lightyears/bot/story.py @@ -0,0 +1,94 @@ +from pathlib import Path + +import discord +from utils.view import UserOnlyView + + +class StoryPage: + """A page in a story, with a title, description, and optional image.""" + + def __init__( + self, + title: str, + description: str, + image_path: Path | None = None, + color: discord.Color = discord.Color.blurple(), # noqa: B008 + ) -> None: + self.title = title + self.description = description + self.image_path = image_path + self.color = color + + def embed(self) -> discord.Embed: + """Return an embed with the image.""" + embed = discord.Embed(title=self.title, description=self.description, color=self.color) + if self.image_path: + embed.set_image(url=f"attachment://{self.image_path.name}") + return embed + + def attachments(self) -> list[discord.File]: + """Return the image as a discord.py File object.""" + if not self.image_path: + return [] + return [discord.File(self.image_path, filename=self.image_path.name)] + + +class StoryView(UserOnlyView): + """Story pages, with an image/text in an embed, allowing the user to continue.""" + + def __init__( + self, + pages: list[StoryPage], + user: discord.User | discord.Member, + continue_button_style: discord.ButtonStyle = discord.ButtonStyle.primary, + ) -> None: + super().__init__(original_user=user) + if len(pages) < 1: + raise ValueError + self.pages = pages + self.current_page = 0 + self.continue_button.style = continue_button_style + self.last_interaction: discord.Interaction | None = None + + def _next_page(self) -> None: + """Turn to the next page only, but don't send the response. + + Raises ValueError if there are no more pages to turn to. + """ + self.current_page += 1 + if self.current_page >= len(self.pages): + raise ValueError + + def first_embed(self) -> discord.Embed: + """Show the first page.""" + page = self.pages[self.current_page] + return page.embed() + + def first_attachments(self) -> list[discord.File]: + """Show the first page.""" + page = self.pages[self.current_page] + return page.attachments() + + async def show_page(self, interaction: discord.Interaction) -> None: + """Show the current page.""" + page = self.pages[self.current_page] + await interaction.response.edit_message(embed=page.embed(), attachments=page.attachments(), view=self) + + async def show_next_page(self, interaction: discord.Interaction) -> None: + """Show the next page.""" + try: + self._next_page() + except ValueError: + self.end(interaction) + else: + await self.show_page(interaction) + + def end(self, interaction: discord.Interaction) -> None: + """End the story.""" + self.last_interaction = interaction + self.stop() + + @discord.ui.button(label="Continue", style=discord.ButtonStyle.primary) + async def continue_button(self, interaction: discord.Interaction, _: discord.ui.Button) -> None: + """Show the next page.""" + await self.show_next_page(interaction) diff --git a/mesmerizing-lightyears/bot/tests/__init__.py b/mesmerizing-lightyears/bot/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mesmerizing-lightyears/bot/tests/test_play_models.py b/mesmerizing-lightyears/bot/tests/test_play_models.py new file mode 100644 index 0000000..8fe0da0 --- /dev/null +++ b/mesmerizing-lightyears/bot/tests/test_play_models.py @@ -0,0 +1,110 @@ +import unittest + +from database.models import PlayDetail, Player + + +class TestDB: + """Fake Player repository.""" + + def __init__(self) -> None: + self.db = [] + + def get(self, username: str) -> Player: + """Get player detail.""" + details = [row for row in self.db if row["username"] == username] + return Player(username, details) + + def save(self, player: Player) -> None: + """Append new play data.""" + data = player.new_data + if data: + for row in data: + self.db.append(row) + + +def populate_db() -> TestDB: + """Populate test db.""" + test_db = TestDB() + data = [ + {"username": "noble", "level": 1, "score": 125}, + {"username": "noble", "level": 2, "score": 230}, + {"username": "noble", "level": 3, "score": 100}, + {"username": "noble", "level": 2, "score": 150}, + {"username": "noble", "level": 1, "score": 300}, + ] + + test_db.db.extend(data) + + return test_db + + +class TestModel(unittest.TestCase): + """Test class for models. + + Tests to be carried out: + - ensure that saving a player detail only adds the new plays and doesn't duplicate old plays + - test player next_level property + - test unlocked levels + - ensure that `player.summary` return levels with the right data + - test dynamic play history access + - test saving a new user works + """ + + def test_level_access(self) -> None: + """Test history access by level.""" + test_db = populate_db() + player = test_db.get(username="noble") + plays = player.history["lvl2"] + expected_plays = [ + PlayDetail(username="noble", level=2, score=230), + PlayDetail(username="noble", level=2, score=150), + ] + assert plays == expected_plays + + def test_save_player(self) -> None: + """Test PlayHistory `new_plays`.""" + test_db = populate_db() + player = test_db.get(username="noble") + new_play = {"username": "noble", "level": 4, "score": 200, "completed": True} + player.history.append(PlayDetail(**new_play)) + test_db.save(player) + assert new_play in test_db.db + + def test_special_levels_unlocked(self) -> None: + """Test Player `special_levels_unlocked` property.""" + test_db = populate_db() + player = test_db.get(username="noble") + new_play = {"username": "noble", "level": 4, "score": 200, "completed": True} + player.history.append(PlayDetail(**new_play)) + special_level = "lvlA" + assert special_level in player.special_levels_unlocked + + def test_player_summary(self) -> None: + """Test Player summary method.""" + test_db = populate_db() + player = test_db.get(username="noble") + expected_summary = [ + {"lvl_id": 1, "available": True, "completed": True}, + {"lvl_id": 2, "available": True, "completed": True}, + {"lvl_id": 3, "available": True, "completed": True}, + {"lvl_id": 4, "available": True, "completed": False}, + ] + assert player.summary == expected_summary + + def test_save_new_user(self) -> None: + """Test save new user.""" + test_db = populate_db() + new_player = test_db.get(username="doe") + new_play = {"username": "doe", "level": 1, "score": 0, "completed": False} + new_player.history.append(PlayDetail(**new_play)) + test_db.save(new_player) + assert new_play in test_db.db + + def test_level_in_history(self) -> None: + """Test history `__contains__`.""" + player = populate_db().get(username="noble") + assert ("lvl2" in player.history) is True + + +if __name__ == "__main__": + unittest.main() diff --git a/mesmerizing-lightyears/bot/tests/test_score_models.py b/mesmerizing-lightyears/bot/tests/test_score_models.py new file mode 100644 index 0000000..dfa6806 --- /dev/null +++ b/mesmerizing-lightyears/bot/tests/test_score_models.py @@ -0,0 +1,137 @@ +import unittest + +from database.models import Score, ScoreSheet + + +class TestDB: + """Fake Score repository.""" + + def __init__(self) -> None: + self.store = [] + + def add(self, level: int, username: str, score: int) -> None: + """Add user to score board.""" + kwargs = {"username": username, "level": level, "score": score} + self.store.append(kwargs) + + def fetch_scores(self, level: int) -> list[Score]: + """Fetch users from repository.""" + return [score for score in self.store if score["level"] == level] + + def _get(self, kwargs: dict) -> int: + """Get user index.""" + for index, score in enumerate(self.store): + if all((score[k] == kwargs[k]) for k in kwargs): + return index + return None + + def get(self, level: int, username: str, score: int | None = None) -> Score: + """Get user detail.""" + kwargs = {"level": level, "username": username} + if score: + kwargs["score"] = score + index = self._get(kwargs) + if index: + return self.store[index] + return None + + def update(self, level: int, username: str, score: int) -> None: + """Update user score.""" + kwargs = {"level": level, "username": username} + index = self._get(kwargs) + if index: + self.store[index]["score"] = score + + def remove(self, level: int, username: str) -> None: + """Remove user.""" + kwargs = {"level": level, "username": username} + index = self._get(kwargs) + if index: + self.store.pop(index) + + +class TestModels(unittest.TestCase): + """Test class for models.""" + + def populate_db(self) -> TestDB: + """Populate fake database with dummy data.""" + test_db = TestDB() + test_db.add(username="noble", level=1, score=100) + test_db.add(username="dejavu", level=2, score=125) + test_db.add(username="jasper", level=2, score=150) + test_db.add(username="heavenmercy", level=3, score=250) + test_db.add(username="verstergurkan", level=3, score=225) + return test_db + + def test_scoresheet_level(self) -> None: + """Test level property. + + Check if the score row in scoresheet + object are those that belong to its + level. + """ + test_db = self.populate_db() + rows = 2 + scoresheet = ScoreSheet(db=test_db, level=2) + assert len(scoresheet) == rows + + def test_score_in_scoresheet(self) -> None: + """Test score in scoresheet. + + Check that a specific score is in scoresheet. + """ + test_db = self.populate_db() + scoresheet = ScoreSheet(db=test_db, level=2) + score_obj = Score(username="dejavu", level=2, score=125) + assert score_obj in scoresheet + + def test_scoresheet_sorted(self) -> None: + """Test sort method.""" + test_db = self.populate_db() + scoresheet = ScoreSheet(db=test_db, level=2) + ordered_score_list = [ + Score(username="jasper", level=2, score=150), + Score(username="dejavu", level=2, score=125), + ] + + scoresheet_list = scoresheet.sort() + assert ordered_score_list == scoresheet_list + + """ + scoresheet only fetch from database table + def test_scoresheet_add_saves_to_database(self) -> None: + "Test scoresheet syncs with database." + test_db = self.populate_db() + scoresheet = ScoreSheet(db=test_db, level=2) + scoresheet.add(username="noble", score=100) + assert test_db.get(username="noble", score=100, level=2) is not None + """ + + """ + def test_update_scoresheet(self) -> None: + "Test update method." + test_db = self.populate_db() + scoresheet = ScoreSheet(db=test_db, level=3) + score = 300 + scoresheet.update(username="heavenmercy", score=score) + + # check scoresheet + score_obj = scoresheet.get("heavenmercy") + assert score_obj.score == score + + # check database + row = test_db.get(username="heavenmercy", level=3) + assert row["score"] == score + """ + + def test_remove_score_from_scoresheet(self) -> None: + """Test remove method.""" + test_db = self.populate_db() + scoresheet = ScoreSheet(db=test_db, level=1) + score_obj = scoresheet.get(username="noble") + scoresheet.remove(username="noble") + assert score_obj not in scoresheet + + +if __name__ == "__main__": + unittest.main() diff --git a/mesmerizing-lightyears/bot/utils/__init__.py b/mesmerizing-lightyears/bot/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mesmerizing-lightyears/bot/utils/eval.py b/mesmerizing-lightyears/bot/utils/eval.py new file mode 100644 index 0000000..440bb46 --- /dev/null +++ b/mesmerizing-lightyears/bot/utils/eval.py @@ -0,0 +1,8 @@ +import async_tio + + +async def eval_python(code: str) -> str: + """Evaluate Python 3 code and return the output.""" + async with async_tio.Tio() as runner: + output = await runner.execute(code, language="python3") + return output.stdout diff --git a/mesmerizing-lightyears/bot/utils/view.py b/mesmerizing-lightyears/bot/utils/view.py new file mode 100644 index 0000000..166cc52 --- /dev/null +++ b/mesmerizing-lightyears/bot/utils/view.py @@ -0,0 +1,16 @@ +import discord + + +class UserOnlyView(discord.ui.View): + """A view that only allows the original author to interact with it, and doesn't time out.""" + + def __init__(self, *args, original_user: discord.User | discord.Member, **kwargs) -> None: # noqa: ANN002, ANN003 + super().__init__(*args, **kwargs, timeout=None) + self.original_user = original_user + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + """Check if the interaction is from the original author.""" + if interaction.user == self.original_user: + return True + await interaction.response.send_message("You can't interact with this view.", ephemeral=True) + return False diff --git a/mesmerizing-lightyears/discord_guide.md b/mesmerizing-lightyears/discord_guide.md new file mode 100644 index 0000000..13c09e5 --- /dev/null +++ b/mesmerizing-lightyears/discord_guide.md @@ -0,0 +1,35 @@ +# Discord Developers Guide + + +## How to set up your own bot to run the project + +1. Log in to **[Discord Developer Portal](https://discord.com/developers/)** +2. Create your new project with the ``New Application`` button. +3. Activate the ``PRESENCE INTENT``, ``SERVER MEMBERS INTENT``, ``MESSAGE CONTENT INTENT`` in the ``Bot > Privileged Gateway Intents`` section. +4. After creating your project, make the necessary settings in the ``Settings > OAuth2`` tab. + - Select the ``bot`` option from the OAuth2 URL Generator section + - Below, select the permissions you wish for the bot to have in the server. We recommend `Administrator` for testing purposes +5. After clicking on the ``bot``, you can choose the permissions your bot will have from the window that opens. + + **Recommended settings:** + ```` + Bot > General Permissions + - Manage Expressions + - Create Expressions + - View Channels + - View Server Insights + + Bot > Text Permissions + - Send Messages + - Manage Messages + - Embed Links + - Attach Files + - Use External Emojis + - Use External Stickers + - Add Reactions + - Use Slash Commands + - Use Embeded activites + - Create Polls + ```` + - Depending on the options you set, you can add your bot to your server by opening the ``GENERATED URL`` in your browser, authenticating with discord, and adding it to a server of your choosing. + - Make sure you give your bot enough room to play. diff --git a/mesmerizing-lightyears/presentation/CJ_architecture.png b/mesmerizing-lightyears/presentation/CJ_architecture.png new file mode 100644 index 0000000..0a8d11a Binary files /dev/null and b/mesmerizing-lightyears/presentation/CJ_architecture.png differ diff --git a/mesmerizing-lightyears/presentation/hints.png b/mesmerizing-lightyears/presentation/hints.png new file mode 100644 index 0000000..f8a0f09 Binary files /dev/null and b/mesmerizing-lightyears/presentation/hints.png differ diff --git a/mesmerizing-lightyears/presentation/level-statuses.png b/mesmerizing-lightyears/presentation/level-statuses.png new file mode 100644 index 0000000..50e0de9 Binary files /dev/null and b/mesmerizing-lightyears/presentation/level-statuses.png differ diff --git a/mesmerizing-lightyears/presentation/levels.png b/mesmerizing-lightyears/presentation/levels.png new file mode 100644 index 0000000..a927bc5 Binary files /dev/null and b/mesmerizing-lightyears/presentation/levels.png differ diff --git a/mesmerizing-lightyears/presentation/map-navigation.png b/mesmerizing-lightyears/presentation/map-navigation.png new file mode 100644 index 0000000..ff206d0 Binary files /dev/null and b/mesmerizing-lightyears/presentation/map-navigation.png differ diff --git a/mesmerizing-lightyears/pyproject.toml b/mesmerizing-lightyears/pyproject.toml new file mode 100644 index 0000000..f0639bd --- /dev/null +++ b/mesmerizing-lightyears/pyproject.toml @@ -0,0 +1,51 @@ +[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", + # Too many function arguments. + "PLR0913", + "C901", +] + +# Ignore assert error for tests +[tool.ruff.lint.per-file-ignores] +"bot/tests/*.py" = ["S101"] diff --git a/mesmerizing-lightyears/requirements-dev.txt b/mesmerizing-lightyears/requirements-dev.txt new file mode 100644 index 0000000..d529f2e --- /dev/null +++ b/mesmerizing-lightyears/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/mesmerizing-lightyears/requirements.txt b/mesmerizing-lightyears/requirements.txt new file mode 100644 index 0000000..472503c --- /dev/null +++ b/mesmerizing-lightyears/requirements.txt @@ -0,0 +1,5 @@ +async-tio==1.3.2 +discord~=2.3 +python-dotenv~=1.0 +pillow==10.4.0 +matplotlib==3.9.1 diff --git a/mesmerizing-lightyears/samples/Pipfile b/mesmerizing-lightyears/samples/Pipfile new file mode 100644 index 0000000..27673c0 --- /dev/null +++ b/mesmerizing-lightyears/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/mesmerizing-lightyears/samples/pyproject.toml b/mesmerizing-lightyears/samples/pyproject.toml new file mode 100644 index 0000000..835045d --- /dev/null +++ b/mesmerizing-lightyears/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"