diff options
author | 2020-03-31 13:56:39 +0700 | |
---|---|---|
committer | 2020-03-31 13:56:39 +0700 | |
commit | 0a29c393cad5b66b67456a40f8273c1fc3d6271f (patch) | |
tree | 74ff29a7fa10e6d2cb5d8d63065e3a5cc91753d3 | |
parent | (Space Cog): Removed `async` from `create_nasa_embed` function. (diff) | |
parent | Merge pull request #383 from python-discord/setup-sentry (diff) |
Merge branch 'master' into space-cog
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | CONTRIBUTING.md | 63 | ||||
-rw-r--r-- | Pipfile | 1 | ||||
-rw-r--r-- | Pipfile.lock | 78 | ||||
-rw-r--r-- | bot/__main__.py | 13 | ||||
-rw-r--r-- | bot/constants.py | 1 | ||||
-rw-r--r-- | bot/seasons/evergreen/battleship.py | 444 | ||||
-rw-r--r-- | bot/seasons/evergreen/error_handler.py | 21 |
8 files changed, 568 insertions, 54 deletions
@@ -1,5 +1,6 @@ # bot (project-specific) log/* +data/* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ced5df70..e1fa39ec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,12 +14,12 @@ Note that contributions may be rejected on the basis of a contributor failing to 4. If you have direct access to the repository, **create a branch for your changes** and create a pull request for that branch. If not, create a branch on a fork of the repository and create a pull request from there. * It's common practice for a repository to reject direct pushes to `master`, so make branching a habit! * If PRing from your own fork, **ensure that "Allow edits from maintainers" is checked**. This gives permission for maintainers to commit changes directly to your fork, speeding up the review process. -5. **Adhere to the prevailing code style**, which we enforce using [flake8](http://flake8.pycqa.org/en/latest/index.html). - * Run `flake8` against your code **before** you push it. Your commit will be rejected by the build server if it fails to lint. - * [Git Hooks](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks) are a powerful tool that can be a daunting to set up. Fortunately, [`pre-commit`](https://github.com/pre-commit/pre-commit) abstracts this process away from you and is provided as a dev dependency for this project. Run `pipenv run precommit` when setting up the project and you'll never have to worry about breaking the build for linting errors. +5. **Adhere to the prevailing code style**, which we enforce using [`flake8`](http://flake8.pycqa.org/en/latest/index.html) and [`pre-commit`](https://pre-commit.com/). + * Run `flake8` and `pre-commit` against your code [**before** you push it](https://soundcloud.com/lemonsaurusrex/lint-before-you-push). Your commit will be rejected by the build server if it fails to lint. + * [Git Hooks](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks) are a powerful git feature for executing custom scripts when certain important git actions occur. The pre-commit hook is the first hook executed during the commit process and can be used to check the code being committed & abort the commit if issues, such as linting failures, are detected. While git hooks can seem daunting to configure, the `pre-commit` framework abstracts this process away from you and is provided as a dev dependency for this project. Run `pipenv run precommit` when setting up the project and you'll never have to worry about committing code that fails linting. 6. **Make great commits**. A well structured git log is key to a project's maintainability; it efficiently provides insight into when and *why* things were done for future maintainers of the project. * Commits should be as narrow in scope as possible. Commits that span hundreds of lines across multiple unrelated functions and/or files are very hard for maintainers to follow. After about a week they'll probably be hard for you to follow too. - * Try to avoid making minor commits for fixing typos or linting errors. Since you've already set up a pre-commit hook to run `flake8` before a commit, you shouldn't be committing linting issues anyway. + * Avoid making minor commits for fixing typos or linting errors. Since you've already set up a `pre-commit` hook to run the linting pipeline before a commit, you shouldn't be committing linting issues anyway. * A more in-depth guide to writing great commit messages can be found in Chris Beam's [*How to Write a Git Commit Message*](https://chris.beams.io/posts/git-commit/) 7. **Avoid frequent pushes to the main repository**. This goes for PRs opened against your fork as well. Our test build pipelines are triggered every time a push to the repository (or PR) is made. Try to batch your commits until you've finished working for that session, or you've reached a point where collaborators need your commits to continue their own work. This also provides you the opportunity to amend commits for minor changes rather than having to commit them on their own because you've already pushed. * This includes merging master into your branch. Try to leave merging from master for after your PR passes review; a maintainer will bring your PR up to date before merging. Exceptions to this include: resolving merge conflicts, needing something that was pushed to master for your branch, or something was pushed to master that could potentionally affect the functionality of what you're writing. @@ -28,13 +28,12 @@ Note that contributions may be rejected on the basis of a contributor failing to * One option is to fork the other contributor's repository and submit your changes to their branch with your own pull request. We suggest following these guidelines when interacting with their repository as well. * The author(s) of inactive PRs and claimed issues will be be pinged after a week of inactivity for an update. Continued inactivity may result in the issue being released back to the community and/or PR closure. 10. **Work as a team** and collaborate wherever possible. Keep things friendly and help each other out - these are shared projects and nobody likes to have their feet trodden on. -11. **Internal projects are internal**. As a contributor, you have access to information that the rest of the server does not. With this trust comes responsibility - do not release any information you have learned as a result of your contributor position. We are very strict about announcing things at specific times, and many staff members will not appreciate a disruption of the announcement schedule. -12. All static content, such as images or audio, **must be licensed for open public use**. +11. All static content, such as images or audio, **must be licensed for open public use**. * Static content must be hosted by a service designed to do so. Failing to do so is known as "leeching" and is frowned upon, as it generates extra bandwidth costs to the host without providing benefit. It would be best if appropriately licensed content is added to the repository itself so it can be served by PyDis' infrastructure. -Above all, the needs of our community should come before the wants of an individual. Work together, build solutions to problems and try to do so in a way that people can learn from easily. Abuse of our trust may result in the loss of your Contributor role, especially in relation to Rule 7. +Above all, the needs of our community should come before the wants of an individual. Work together, build solutions to problems and try to do so in a way that people can learn from easily. Abuse of our trust may result in the loss of your Contributor role. -## Changes to this arrangement +## Changes to this Arrangement All projects evolve over time, and this contribution guide is no different. This document is open to pull requests or changes by contributors. If you believe you have something valuable to add or change, please don't hesitate to do so in a PR. @@ -50,10 +49,14 @@ When pulling down changes from GitHub, remember to sync your environment using ` For example: ```py -def foo(input_1: int, input_2: dict) -> bool: +import typing as t + + +def foo(input_1: int, input_2: t.Dict[str, str]) -> bool: + ... ``` -Tells us that `foo` accepts an `int` and a `dict` and returns a `bool`. +Tells us that `foo` accepts an `int` and a `dict`, with `str` keys and values, and returns a `bool`. All function declarations should be type hinted in code contributed to the PyDis organization. @@ -65,15 +68,19 @@ Many documentation packages provide support for automatic documentation generati For example: ```py -def foo(bar: int, baz: dict=None) -> bool: +import typing as t + + +def foo(bar: int, baz: t.Optional[t.Dict[str, str]] = None) -> bool: """ Does some things with some stuff. :param bar: Some input - :param baz: Optional, some other input + :param baz: Optional, some dictionary with string keys and values :return: Some boolean """ + ... ``` Since PyDis does not utilize automatic documentation generation, use of this syntax should not be used in code contributed to the organization. Should the purpose and type of the input variables not be easily discernable from the variable name and type annotation, a prose explanation can be used. Explicit references to variables, functions, classes, etc. should be wrapped with backticks (`` ` ``). @@ -81,27 +88,33 @@ Since PyDis does not utilize automatic documentation generation, use of this syn For example, the above docstring would become: ```py -def foo(bar: int, baz: dict=None) -> bool: +import typing as t + + +def foo(bar: int, baz: t.Optional[t.Dict[str, str]] = None) -> bool: """ Does some things with some stuff. - This function takes an index, `bar` and checks for its presence in the database `baz`, passed as a dictionary. - - Returns `False` if `baz` is not passed. + This function takes an index, `bar` and checks for its presence in the database `baz`, passed as a dictionary. Returns `False` if `baz` is not passed. """ + ... ``` -### Logging levels -The project currently defines [`logging`](https://docs.python.org/3/library/logging.html) levels as follows: -* **TRACE:** Use this for tracing every step of a complex process. That way we can see which step of the process failed. Err on the side of verbose. **Note:** This is a PyDis-implemented logging level. -* **DEBUG:** Someone is interacting with the application, and the application is behaving as expected. -* **INFO:** Something completely ordinary happened. Like a cog loading during startup. -* **WARNING:** Someone is interacting with the application in an unexpected way or the application is responding in an unexpected way, but without causing an error. -* **ERROR:** An error that affects the specific part that is being interacted with -* **CRITICAL:** An error that affects the whole application. +### Logging Levels +The project currently defines [`logging`](https://docs.python.org/3/library/logging.html) levels as follows, from lowest to highest severity: +* **TRACE:** These events should be used to provide a *verbose* trace of every step of a complex process. This is essentially the `logging` equivalent of sprinkling `print` statements throughout the code. + * **Note:** This is a PyDis-implemented logging level. +* **DEBUG:** These events should add context to what's happening in a development setup to make it easier to follow what's going while working on a project. This is in the same vein as **TRACE** logging but at a much lower level of verbosity. +* **INFO:** These events are normal and don't need direct attention but are worth keeping track of in production, like checking which cogs were loaded during a start-up. +* **WARNING:** These events are out of the ordinary and should be fixed, but have not caused a failure. + * **NOTE:** Events at this logging level and higher should be reserved for events that require the attention of the DevOps team. +* **ERROR:** These events have caused a failure in a specific part of the application and require urgent attention. +* **CRITICAL:** These events have caused the whole application to fail and require immediate intervention. + +Ensure that log messages are succinct. Should you want to pass additional useful information that would otherwise make the log message overly verbose the `logging` module accepts an `extra` kwarg, which can be used to pass a dictionary. This is used to populate the `__dict__` of the `LogRecord` created for the logging event with user-defined attributes that can be accessed by a log handler. Additional information and caveats may be found [in Python's `logging` documentation](https://docs.python.org/3/library/logging.html#logging.Logger.debug). ### Work in Progress (WIP) PRs -Github [has introduced a new PR feature](https://github.blog/2019-02-14-introducing-draft-pull-requests/) that allows the PR author to mark it as a WIP. This provides both a visual and functional indicator that the contents of the PR are in a draft state and not yet ready for formal review. +Github [provides a PR feature](https://github.blog/2019-02-14-introducing-draft-pull-requests/) that allows the PR author to mark it as a WIP. This provides both a visual and functional indicator that the contents of the PR are in a draft state and not yet ready for formal review. This feature should be utilized in place of the traditional method of prepending `[WIP]` to the PR title. @@ -11,6 +11,7 @@ discord-py = "~=1.3.2" fuzzywuzzy = "~=0.17" pillow = "~=6.2" pytz = "~=2019.2" +sentry-sdk = "~=0.14.2" [dev-packages] flake8 = "~=3.7" diff --git a/Pipfile.lock b/Pipfile.lock index 426514e5..ad92fcf1 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "b117417a1dbcc28039ecac9579d54efa6437c621f0132eb06a8aa4f990d30a00" + "sha256": "d1c0feb610f6742095c50eb3049fdd308603c17e1a785c03d9bf703eda0af985" }, "pipfile-spec": 6, "requires": { @@ -72,6 +72,13 @@ "index": "pypi", "version": "==4.8.2" }, + "certifi": { + "hashes": [ + "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3", + "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f" + ], + "version": "==2019.11.28" + }, "cffi": { "hashes": [ "sha256:001bf3242a1bb04d985d63e138230802c6c8d4db3668fb545fb5005ddf5bb5ff", @@ -248,6 +255,14 @@ "index": "pypi", "version": "==2019.3" }, + "sentry-sdk": { + "hashes": [ + "sha256:23808d571d2461a4ce3784ec12bbee5bdb8c026c143fe79d36cef8a6d653e71f", + "sha256:bb90a4e19c7233a580715fc986cc44be2c48fc10b31e71580a2037e1c94b6950" + ], + "index": "pypi", + "version": "==0.14.3" + }, "six": { "hashes": [ "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a", @@ -262,6 +277,13 @@ ], "version": "==2.0" }, + "urllib3": { + "hashes": [ + "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc", + "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc" + ], + "version": "==1.25.8" + }, "websockets": { "hashes": [ "sha256:0e4fb4de42701340bd2353bb2eee45314651caa6ccee80dbd5f5d5978888fed5", @@ -411,11 +433,11 @@ }, "flake8-tidy-imports": { "hashes": [ - "sha256:8aa34384b45137d4cf33f5818b8e7897dc903b1d1e10a503fa7dd193a9a710ba", - "sha256:b26461561bcc80e8012e46846630ecf0aaa59314f362a94cb7800dfdb32fa413" + "sha256:62059ca07d8a4926b561d392cbab7f09ee042350214a25cf12823384a45d27dd", + "sha256:c30b40337a2e6802ba3bb611c26611154a27e94c53fc45639e3e282169574fd3" ], "index": "pypi", - "version": "==4.0.0" + "version": "==4.1.0" }, "flake8-todo": { "hashes": [ @@ -426,10 +448,10 @@ }, "identify": { "hashes": [ - "sha256:1222b648251bdcb8deb240b294f450fbf704c7984e08baa92507e4ea10b436d5", - "sha256:d824ebe21f38325c771c41b08a95a761db1982f1fc0eee37c6c97df3f1636b96" + "sha256:a7577a1f55cee1d21953a5cf11a3c839ab87f5ef909a4cba6cf52ed72b4c6059", + "sha256:ab246293e6585a1c6361a505b68d5b501a0409310932b7de2c2ead667b564d89" ], - "version": "==1.4.11" + "version": "==1.4.13" }, "mccabe": { "hashes": [ @@ -446,19 +468,19 @@ }, "pep8-naming": { "hashes": [ - "sha256:45f330db8fcfb0fba57458c77385e288e7a3be1d01e8ea4268263ef677ceea5f", - "sha256:a33d38177056321a167decd6ba70b890856ba5025f0a8eca6a3eda607da93caf" + "sha256:5d9f1056cb9427ce344e98d1a7f5665710e2f20f748438e308995852cfa24164", + "sha256:f3b4a5f9dd72b991bf7d8e2a341d2e1aa3a884a769b5aaac4f56825c1763bf3a" ], "index": "pypi", - "version": "==0.9.1" + "version": "==0.10.0" }, "pre-commit": { "hashes": [ - "sha256:09ebe467f43ce24377f8c2f200fe3cd2570d328eb2ce0568c8e96ce19da45fa6", - "sha256:f8d555e31e2051892c7f7b3ad9f620bd2c09271d87e9eedb2ad831737d6211eb" + "sha256:487c675916e6f99d355ec5595ad77b325689d423ef4839db1ed2f02f639c9522", + "sha256:c0aa11bce04a7b46c5544723aedf4e81a4d5f64ad1205a30a9ea12d5e81969e1" ], "index": "pypi", - "version": "==2.1.1" + "version": "==2.2.0" }, "pycodestyle": { "hashes": [ @@ -483,19 +505,19 @@ }, "pyyaml": { "hashes": [ - "sha256:059b2ee3194d718896c0ad077dd8c043e5e909d9180f387ce42012662a4946d6", - "sha256:1cf708e2ac57f3aabc87405f04b86354f66799c8e62c28c5fc5f88b5521b2dbf", - "sha256:24521fa2890642614558b492b473bee0ac1f8057a7263156b02e8b14c88ce6f5", - "sha256:4fee71aa5bc6ed9d5f116327c04273e25ae31a3020386916905767ec4fc5317e", - "sha256:70024e02197337533eef7b85b068212420f950319cc8c580261963aefc75f811", - "sha256:74782fbd4d4f87ff04159e986886931456a1894c61229be9eaf4de6f6e44b99e", - "sha256:940532b111b1952befd7db542c370887a8611660d2b9becff75d39355303d82d", - "sha256:cb1f2f5e426dc9f07a7681419fe39cee823bb74f723f36f70399123f439e9b20", - "sha256:dbbb2379c19ed6042e8f11f2a2c66d39cceb8aeace421bfc29d085d93eda3689", - "sha256:e3a057b7a64f1222b56e47bcff5e4b94c4f61faac04c7c4ecb1985e18caa3994", - "sha256:e9f45bd5b92c7974e59bcd2dcc8631a6b6cc380a904725fce7bc08872e691615" + "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", + "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76", + "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", + "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648", + "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", + "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f", + "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2", + "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", + "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d", + "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c", + "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a" ], - "version": "==5.3" + "version": "==5.3.1" }, "six": { "hashes": [ @@ -520,10 +542,10 @@ }, "virtualenv": { "hashes": [ - "sha256:5eba85dfa176fde0425b9b3042ed83f05a1b6309a616b8a3e2a9a94f4bfa27b7", - "sha256:99f131be2f90ff2a8fd711261a27845b6c50fc008bef815e710c7fa844eb1467" + "sha256:6f4c2882a943d20714076679f8dcc5675e953d6c29bfea3bc5d08bb6cdea5d36", + "sha256:cb1dab893f9e39b3e68d9118c555dcd86526d531c128c3f72e1551939723b72f" ], - "version": "==20.0.9" + "version": "==20.0.14" } } } diff --git a/bot/__main__.py b/bot/__main__.py index a169257f..2e68a9a4 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -1,9 +1,22 @@ import logging +import sentry_sdk +from sentry_sdk.integrations.logging import LoggingIntegration + from bot.bot import bot from bot.constants import Client, STAFF_ROLES, WHITELISTED_CHANNELS from bot.decorators import in_channel_check +sentry_logging = LoggingIntegration( + level=logging.DEBUG, + event_level=logging.WARNING +) + +sentry_sdk.init( + dsn=Client.sentry_dsn, + integrations=[sentry_logging] +) + log = logging.getLogger(__name__) bot.add_check(in_channel_check(*WHITELISTED_CHANNELS, bypass_roles=STAFF_ROLES)) diff --git a/bot/constants.py b/bot/constants.py index a868370b..e0737e86 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -66,6 +66,7 @@ class Client(NamedTuple): guild = int(environ.get("SEASONALBOT_GUILD", 267624335836053506)) prefix = environ.get("PREFIX", ".") token = environ.get("SEASONALBOT_TOKEN") + sentry_dsn = environ.get("SEASONALBOT_SENTRY_DSN") debug = environ.get("SEASONALBOT_DEBUG", "").lower() == "true" season_override = environ.get("SEASON_OVERRIDE") icon_cycle_frequency = 3 # N days to wait between cycling server icons within a single season diff --git a/bot/seasons/evergreen/battleship.py b/bot/seasons/evergreen/battleship.py new file mode 100644 index 00000000..9b8aaa48 --- /dev/null +++ b/bot/seasons/evergreen/battleship.py @@ -0,0 +1,444 @@ +import asyncio +import logging +import random +import re +import typing +from dataclasses import dataclass +from functools import partial + +import discord +from discord.ext import commands + +from bot.constants import Colours + +log = logging.getLogger(__name__) + + +@dataclass +class Square: + """Each square on the battleship grid - if they contain a boat and if they've been aimed at.""" + + boat: typing.Optional[str] + aimed: bool + + +Grid = typing.List[typing.List[Square]] +EmojiSet = typing.Dict[typing.Tuple[bool, bool], str] + + +@dataclass +class Player: + """Each player in the game - their messages for the boards and their current grid.""" + + user: discord.Member + board: discord.Message + opponent_board: discord.Message + grid: Grid + + +# The name of the ship and its size +SHIPS = { + "Carrier": 5, + "Battleship": 4, + "Cruiser": 3, + "Submarine": 3, + "Destroyer": 2, +} + + +# For these two variables, the first boolean is whether the square is a ship (True) or not (False). +# The second boolean is whether the player has aimed for that square (True) or not (False) + +# This is for the player's own board which shows the location of their own ships. +SHIP_EMOJIS = { + (True, True): ":fire:", + (True, False): ":ship:", + (False, True): ":anger:", + (False, False): ":ocean:", +} + +# This is for the opposing player's board which only shows aimed locations. +HIDDEN_EMOJIS = { + (True, True): ":red_circle:", + (True, False): ":black_circle:", + (False, True): ":white_circle:", + (False, False): ":black_circle:", +} + +# For the top row of the board +LETTERS = ( + ":stop_button::regional_indicator_a::regional_indicator_b::regional_indicator_c::regional_indicator_d:" + ":regional_indicator_e::regional_indicator_f::regional_indicator_g::regional_indicator_h:" + ":regional_indicator_i::regional_indicator_j:" +) + +# For the first column of the board +NUMBERS = [ + ":one:", + ":two:", + ":three:", + ":four:", + ":five:", + ":six:", + ":seven:", + ":eight:", + ":nine:", + ":keycap_ten:", +] + +CROSS_EMOJI = "\u274e" +HAND_RAISED_EMOJI = "\U0001f64b" + + +class Game: + """A Battleship Game.""" + + def __init__( + self, + bot: commands.Bot, + channel: discord.TextChannel, + player1: discord.Member, + player2: discord.Member + ) -> None: + + self.bot = bot + self.public_channel = channel + + self.p1 = Player(player1, None, None, self.generate_grid()) + self.p2 = Player(player2, None, None, self.generate_grid()) + + self.gameover: bool = False + + self.turn: typing.Optional[discord.Member] = None + self.next: typing.Optional[discord.Member] = None + + self.match: typing.Optional[typing.Match] = None + self.surrender: bool = False + + self.setup_grids() + + @staticmethod + def generate_grid() -> Grid: + """Generates a grid by instantiating the Squares.""" + return [[Square(None, False) for _ in range(10)] for _ in range(10)] + + @staticmethod + def format_grid(player: Player, emojiset: EmojiSet) -> str: + """ + Gets and formats the grid as a list into a string to be output to the DM. + + Also adds the Letter and Number indexes. + """ + grid = [ + [emojiset[bool(square.boat), square.aimed] for square in row] + for row in player.grid + ] + + rows = ["".join([number] + row) for number, row in zip(NUMBERS, grid)] + return "\n".join([LETTERS] + rows) + + @staticmethod + def get_square(grid: Grid, square: str) -> Square: + """Grabs a square from a grid with an inputted key.""" + index = ord(square[0]) - ord("A") + number = int(square[1:]) + + return grid[number-1][index] # -1 since lists are indexed from 0 + + async def game_over( + self, + *, + winner: discord.Member, + loser: discord.Member + ) -> None: + """Removes games from list of current games and announces to public chat.""" + await self.public_channel.send(f"Game Over! {winner.mention} won against {loser.mention}") + + for player in (self.p1, self.p2): + grid = self.format_grid(player, SHIP_EMOJIS) + await self.public_channel.send(f"{player.user}'s Board:\n{grid}") + + @staticmethod + def check_sink(grid: Grid, boat: str) -> bool: + """Checks if all squares containing a given boat have sunk.""" + return all(square.aimed for row in grid for square in row if square.boat == boat) + + @staticmethod + def check_gameover(grid: Grid) -> bool: + """Checks if all boats have been sunk.""" + return all(square.aimed for row in grid for square in row if square.boat) + + def setup_grids(self) -> None: + """Places the boats on the grids to initialise the game.""" + for player in (self.p1, self.p2): + for name, size in SHIPS.items(): + while True: # Repeats if about to overwrite another boat + ship_collision = False + coords = [] + + coord1 = random.randint(0, 9) + coord2 = random.randint(0, 10 - size) + + if random.choice((True, False)): # Vertical or Horizontal + x, y = coord1, coord2 + xincr, yincr = 0, 1 + else: + x, y = coord2, coord1 + xincr, yincr = 1, 0 + + for i in range(size): + new_x = x + (xincr * i) + new_y = y + (yincr * i) + if player.grid[new_x][new_y].boat: # Check if there's already a boat + ship_collision = True + break + coords.append((new_x, new_y)) + if not ship_collision: # If not overwriting any other boat spaces, break loop + break + + for x, y in coords: + player.grid[x][y].boat = name + + async def print_grids(self) -> None: + """Prints grids to the DM channels.""" + # Convert squares into Emoji + + boards = [ + self.format_grid(player, emojiset) + for emojiset in (HIDDEN_EMOJIS, SHIP_EMOJIS) + for player in (self.p1, self.p2) + ] + + locations = ( + (self.p2, "opponent_board"), (self.p1, "opponent_board"), + (self.p1, "board"), (self.p2, "board") + ) + + for board, location in zip(boards, locations): + player, attr = location + if getattr(player, attr): + await getattr(player, attr).edit(content=board) + else: + setattr(player, attr, await player.user.send(board)) + + def predicate(self, message: discord.Message) -> bool: + """Predicate checking the message typed for each turn.""" + if message.author == self.turn.user and message.channel == self.turn.user.dm_channel: + if message.content.lower() == "surrender": + self.surrender = True + return True + self.match = re.match("([A-J]|[a-j]) ?((10)|[1-9])", message.content.strip()) + if not self.match: + self.bot.loop.create_task(message.add_reaction(CROSS_EMOJI)) + return bool(self.match) + + async def take_turn(self) -> typing.Optional[Square]: + """Lets the player who's turn it is choose a square.""" + square = None + turn_message = await self.turn.user.send( + "It's your turn! Type the square you want to fire at. Format it like this: A1\n" + "Type `surrender` to give up" + ) + await self.next.user.send("Their turn", delete_after=3.0) + while True: + try: + await self.bot.wait_for("message", check=self.predicate, timeout=60.0) + except asyncio.TimeoutError: + await self.turn.user.send("You took too long. Game over!") + await self.next.user.send(f"{self.turn.user} took too long. Game over!") + await self.public_channel.send( + f"Game over! {self.turn.user.mention} timed out so {self.next.user.mention} wins!" + ) + self.gameover = True + break + else: + if self.surrender: + await self.next.user.send(f"{self.turn.user} surrendered. Game over!") + await self.public_channel.send( + f"Game over! {self.turn.user.mention} surrendered to {self.next.user.mention}!" + ) + self.gameover = True + break + square = self.get_square(self.next.grid, self.match.string) + if square.aimed: + await self.turn.user.send("You've already aimed at this square!", delete_after=3.0) + else: + break + await turn_message.delete() + return square + + async def hit(self, square: Square, alert_messages: typing.List[discord.Message]) -> None: + """Occurs when a player successfully aims for a ship.""" + await self.turn.user.send("Hit!", delete_after=3.0) + alert_messages.append(await self.next.user.send("Hit!")) + if self.check_sink(self.next.grid, square.boat): + await self.turn.user.send(f"You've sunk their {square.boat} ship!", delete_after=3.0) + alert_messages.append(await self.next.user.send(f"Oh no! Your {square.boat} ship sunk!")) + if self.check_gameover(self.next.grid): + await self.turn.user.send("You win!") + await self.next.user.send("You lose!") + self.gameover = True + await self.game_over(winner=self.turn.user, loser=self.next.user) + + async def start_game(self) -> None: + """Begins the game.""" + await self.p1.user.send(f"You're playing battleship with {self.p2.user}.") + await self.p2.user.send(f"You're playing battleship with {self.p1.user}.") + + alert_messages = [] + + self.turn = self.p1 + self.next = self.p2 + + while True: + await self.print_grids() + + if self.gameover: + return + + square = await self.take_turn() + if not square: + return + square.aimed = True + + for message in alert_messages: + await message.delete() + + alert_messages = [] + alert_messages.append(await self.next.user.send(f"{self.turn.user} aimed at {self.match.string}!")) + + if square.boat: + await self.hit(square, alert_messages) + if self.gameover: + return + else: + await self.turn.user.send("Miss!", delete_after=3.0) + alert_messages.append(await self.next.user.send("Miss!")) + + self.turn, self.next = self.next, self.turn + + +class Battleship(commands.Cog): + """Play the classic game Battleship!""" + + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + self.games: typing.List[Game] = [] + self.waiting: typing.List[discord.Member] = [] + + def predicate( + self, + ctx: commands.Context, + announcement: discord.Message, + reaction: discord.Reaction, + user: discord.Member + ) -> bool: + """Predicate checking the criteria for the announcement message.""" + if self.already_playing(ctx.author): # If they've joined a game since requesting a player 2 + return True # Is dealt with later on + if ( + user.id not in (ctx.me.id, ctx.author.id) + and str(reaction.emoji) == HAND_RAISED_EMOJI + and reaction.message.id == announcement.id + ): + if self.already_playing(user): + self.bot.loop.create_task(ctx.send(f"{user.mention} You're already playing a game!")) + self.bot.loop.create_task(announcement.remove_reaction(reaction, user)) + return False + + if user in self.waiting: + self.bot.loop.create_task(ctx.send( + f"{user.mention} Please cancel your game first before joining another one." + )) + self.bot.loop.create_task(announcement.remove_reaction(reaction, user)) + return False + + return True + + if ( + user.id == ctx.author.id + and str(reaction.emoji) == CROSS_EMOJI + and reaction.message.id == announcement.id + ): + return True + return False + + def already_playing(self, player: discord.Member) -> bool: + """Check if someone is already in a game.""" + return any(player in (game.p1.user, game.p2.user) for game in self.games) + + @commands.group(invoke_without_command=True) + @commands.guild_only() + async def battleship(self, ctx: commands.Context) -> None: + """ + Play a game of Battleship with someone else! + + This will set up a message waiting for someone else to react and play along. + The game takes place entirely in DMs. + Make sure you have your DMs open so that the bot can message you. + """ + if self.already_playing(ctx.author): + return await ctx.send("You're already playing a game!") + + if ctx.author in self.waiting: + return await ctx.send("You've already sent out a request for a player 2") + + announcement = await ctx.send( + "**Battleship**: A new game is about to start!\n" + f"Press {HAND_RAISED_EMOJI} to play against {ctx.author.mention}!\n" + f"(Cancel the game with {CROSS_EMOJI}.)" + ) + self.waiting.append(ctx.author) + await announcement.add_reaction(HAND_RAISED_EMOJI) + await announcement.add_reaction(CROSS_EMOJI) + + try: + reaction, user = await self.bot.wait_for( + "reaction_add", + check=partial(self.predicate, ctx, announcement), + timeout=60.0 + ) + except asyncio.TimeoutError: + self.waiting.remove(ctx.author) + await announcement.delete() + return await ctx.send(f"{ctx.author.mention} Seems like there's no one here to play...") + + if str(reaction.emoji) == CROSS_EMOJI: + self.waiting.remove(ctx.author) + await announcement.delete() + return await ctx.send(f"{ctx.author.mention} Game cancelled.") + + await announcement.delete() + self.waiting.remove(ctx.author) + if self.already_playing(ctx.author): + return + try: + game = Game(self.bot, ctx.channel, ctx.author, user) + self.games.append(game) + await game.start_game() + self.games.remove(game) + except discord.Forbidden: + await ctx.send( + f"{ctx.author.mention} {user.mention} " + "Game failed. This is likely due to you not having your DMs open. Check and try again." + ) + self.games.remove(game) + except Exception: + # End the game in the event of an unforseen error so the players aren't stuck in a game + await ctx.send(f"{ctx.author.mention} {user.mention} An error occurred. Game failed") + self.games.remove(game) + raise + + @battleship.command(name="ships", aliases=["boats"]) + async def battleship_ships(self, ctx: commands.Context) -> None: + """Lists the ships that are found on the battleship grid.""" + embed = discord.Embed(colour=Colours.blue) + embed.add_field(name="Name", value="\n".join(SHIPS)) + embed.add_field(name="Size", value="\n".join(str(size) for size in SHIPS.values())) + await ctx.send(embed=embed) + + +def setup(bot: commands.Bot) -> None: + """Cog load.""" + bot.add_cog(Battleship(bot)) + log.info("Battleship cog loaded") diff --git a/bot/seasons/evergreen/error_handler.py b/bot/seasons/evergreen/error_handler.py index 0d8bb0bb..2753a6df 100644 --- a/bot/seasons/evergreen/error_handler.py +++ b/bot/seasons/evergreen/error_handler.py @@ -5,6 +5,7 @@ from typing import Iterable, Union from discord import Embed, Message from discord.ext import commands +from sentry_sdk import push_scope from bot.constants import Colours, ERROR_REPLIES, NEGATIVE_REPLIES from bot.decorators import InChannelCheckFailure @@ -97,7 +98,25 @@ class CommandErrorHandler(commands.Cog): await ctx.send(embed=self.error_embed("You are not authorized to use this command.", NEGATIVE_REPLIES)) return - log.exception(f"Unhandled command error: {str(error)}", exc_info=error) + with push_scope() as scope: + scope.user = { + "id": ctx.author.id, + "username": str(ctx.author) + } + + scope.set_tag("command", ctx.command.qualified_name) + scope.set_tag("message_id", ctx.message.id) + scope.set_tag("channel_id", ctx.channel.id) + + scope.set_extra("full_message", ctx.message.content) + + if ctx.guild is not None: + scope.set_extra( + "jump_to", + f"https://discordapp.com/channels/{ctx.guild.id}/{ctx.channel.id}/{ctx.message.id}" + ) + + log.exception(f"Unhandled command error: {str(error)}", exc_info=error) def setup(bot: commands.Bot) -> None: |