diff --git a/.gitignore b/.gitignore index 2895fff3..c3ae245c 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,6 @@ ENV/ # PyCharm .idea/ + +# pickle files (tool output) +*.pickle diff --git a/Pipfile b/Pipfile index 096fb9b3..313d468d 100644 --- a/Pipfile +++ b/Pipfile @@ -8,6 +8,11 @@ name = "pypi" aiodns = "*" aiohttp = "<2.3.0,>=2.0.0" websockets = ">=4.0,<5.0" +pynacl = "*" +lxml = "*" +pymarkovchain = "*" +asyncio = "*" +pillow = "*" [dev-packages] "flake8" = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 4e5214bb..1bf9192f 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "d797e580ddcddc99bf058109ab0306ad584c2902752a3d4076ba713fdc580fb7" + "sha256": "ff22acd6853d5f4560288b3395a57fa3f6990c010157368faaf38479a4f6d41c" }, "pipfile-spec": 6, "requires": { @@ -53,6 +53,48 @@ ], "version": "==2.0.1" }, + "asyncio": { + "hashes": [ + "sha256:83360ff8bc97980e4ff25c964c7bd3923d333d177aa4f7fb736b019f26c7cb41", + "sha256:b62c9157d36187eca799c378e572c969f0da87cd5fc42ca372d92cdb06e7e1de", + "sha256:c46a87b48213d7464f22d9a497b9eef8c1928b68320a2fa94240f969f6fec08c", + "sha256:c4d18b22701821de07bd6aea8b53d21449ec0ec5680645e5317062ea21817d2d" + ], + "index": "pypi", + "version": "==3.4.3" + }, + "cffi": { + "hashes": [ + "sha256:151b7eefd035c56b2b2e1eb9963c90c6302dc15fbd8c1c0a83a163ff2c7d7743", + "sha256:1553d1e99f035ace1c0544050622b7bc963374a00c467edafac50ad7bd276aef", + "sha256:1b0493c091a1898f1136e3f4f991a784437fac3673780ff9de3bcf46c80b6b50", + "sha256:2ba8a45822b7aee805ab49abfe7eec16b90587f7f26df20c71dd89e45a97076f", + "sha256:3c85641778460581c42924384f5e68076d724ceac0f267d66c757f7535069c93", + "sha256:3eb6434197633b7748cea30bf0ba9f66727cdce45117a712b29a443943733257", + "sha256:4c91af6e967c2015729d3e69c2e51d92f9898c330d6a851bf8f121236f3defd3", + "sha256:770f3782b31f50b68627e22f91cb182c48c47c02eb405fd689472aa7b7aa16dc", + "sha256:79f9b6f7c46ae1f8ded75f68cf8ad50e5729ed4d590c74840471fc2823457d04", + "sha256:7a33145e04d44ce95bcd71e522b478d282ad0eafaf34fe1ec5bbd73e662f22b6", + "sha256:857959354ae3a6fa3da6651b966d13b0a8bed6bbc87a0de7b38a549db1d2a359", + "sha256:87f37fe5130574ff76c17cab61e7d2538a16f843bb7bca8ebbc4b12de3078596", + "sha256:95d5251e4b5ca00061f9d9f3d6fe537247e145a8524ae9fd30a2f8fbce993b5b", + "sha256:9d1d3e63a4afdc29bd76ce6aa9d58c771cd1599fbba8cf5057e7860b203710dd", + "sha256:a36c5c154f9d42ec176e6e620cb0dd275744aa1d804786a71ac37dc3661a5e95", + "sha256:ae5e35a2c189d397b91034642cb0eab0e346f776ec2eb44a49a459e6615d6e2e", + "sha256:b0f7d4a3df8f06cf49f9f121bead236e328074de6449866515cea4907bbc63d6", + "sha256:b75110fb114fa366b29a027d0c9be3709579602ae111ff61674d28c93606acca", + "sha256:ba5e697569f84b13640c9e193170e89c13c6244c24400fc57e88724ef610cd31", + "sha256:be2a9b390f77fd7676d80bc3cdc4f8edb940d8c198ed2d8c0be1319018c778e1", + "sha256:d5d8555d9bfc3f02385c1c37e9f998e2011f0db4f90e250e5bc0c0a85a813085", + "sha256:e55e22ac0a30023426564b1059b035973ec82186ddddbac867078435801c7801", + "sha256:e90f17980e6ab0f3c2f3730e56d1fe9bcba1891eeea58966e89d352492cc74f4", + "sha256:ecbb7b01409e9b782df5ded849c178a0aa7c906cf8c5a67368047daab282b184", + "sha256:ed01918d545a38998bfa5902c7c00e0fee90e957ce036a4000a88e3fe2264917", + "sha256:edabd457cd23a02965166026fd9bfd196f4324fe6032e866d0f3bd0301cd486f", + "sha256:fdf1c1dc5bafc32bc5d08b054f94d659422b05aba244d6be4ddc1c72d9aa70fb" + ], + "version": "==1.11.5" + }, "chardet": { "hashes": [ "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", @@ -67,6 +109,40 @@ ], "version": "==2.6" }, + "lxml": { + "hashes": [ + "sha256:01c45df6d90497c20aa2a07789a41941f9a1029faa30bf725fc7f6d515b1afe9", + "sha256:0c9fef4f8d444e337df96c54544aeb85b7215b2ed7483bb6c35de97ac99f1bcd", + "sha256:0e3cd94c95d30ba9ca3cff40e9b2a14e1a10a4fd8131105b86c6b61648f57e4b", + "sha256:0e7996e9b46b4d8b4ac1c329a00e2d10edcd8380b95d2a676fccabf4c1dd0512", + "sha256:1858b1933d483ec5727549d3fe166eeb54229fbd6a9d3d7ea26d2c8a28048058", + "sha256:1b164bba1320b14905dcff77da10d5ce9c411ac4acc4fb4ed9a2a4d10fae38c9", + "sha256:1b46f37927fa6cd1f3fe34b54f1a23bd5bea1d905657289e08e1297069a1a597", + "sha256:231047b05907315ae9a9b6925751f9fd2c479cf7b100fff62485a25e382ca0d4", + "sha256:28f0c6652c1b130f1e576b60532f84b19379485eb8da6185c29bd8c9c9bc97bf", + "sha256:34d49d0f72dd82b9530322c48b70ac78cca0911275da741c3b1d2f3603c5f295", + "sha256:3682a17fbf72d56d7e46db2e80ca23850b79c28cfe75dcd9b82f58808f730909", + "sha256:3cf2830b9a6ad7f6e965fa53a768d4d2372a7856f20ffa6ce43d2fe9c0d34b19", + "sha256:5b653c9379ce29ce271fbe1010c5396670f018e78b643e21beefbb3dc6d291de", + "sha256:65a272821d5d8194358d6b46f3ca727fa56a6b63981606eac737c86d27309cdd", + "sha256:691f2cd97cf026c611df1ea5055755eec7f878f2d4f4330dc8686583de6fc5fd", + "sha256:6b6379495d3baacf7ed755ac68547c8dff6ce5d37bf370f0b7678888dc1283f9", + "sha256:75322a531504d4f383264391d89993a42e286da8821ddc5ac315e57305cb84f0", + "sha256:7f457cbda964257f443bac861d3a36732dcba8183149e7818ee2fb7c86901b94", + "sha256:7ff1fc76d8804e0f870c343a72007ff587090c218b0f92d8ee784ac2b6eaf5b9", + "sha256:8523fbde9c2216f3f2b950cb01ebe52e785eaa8a07ffeb456dd3576ca1b4fb9b", + "sha256:8f37627f16e026523fca326f1b5c9a43534862fede6c3e99c2ba6a776d75c1ab", + "sha256:a7182ea298cc3555ea56ffbb0748fe0d5e0d81451e2bc16d7f4645cd01b1ca70", + "sha256:abbd2fb4a5a04c11b5e04eb146659a0cf67bb237dd3d7ca3b9994d3a9f826e55", + "sha256:accc9f6b77bed0a6f267b4fae120f6008a951193d548cdbe9b61fc98a08b1cf8", + "sha256:bd88c8ce0d1504fdfd96a35911dd4f3edfb2e560d7cfdb5a3d09aa571ae5fbae", + "sha256:c557ad647facb3c0027a9d0af58853f905e85a0a2f04dcb73f8e665272fcdc3a", + "sha256:defabb7fbb99f9f7b3e0b24b286a46855caef4776495211b066e9e6592d12b04", + "sha256:e2629cdbcad82b83922a3488937632a4983ecc0fed3e5cfbf430d069382eeb9b" + ], + "index": "pypi", + "version": "==4.2.1" + }, "multidict": { "hashes": [ "sha256:0462372fc74e4c061335118a4a5992b9a618d6c584b028ef03cf3e9b88a960e2", @@ -94,6 +170,53 @@ ], "version": "==4.1.0" }, + "pillow": { + "hashes": [ + "sha256:0013f590a8f260df60bcfd65db19d18efc04e7f046c3c82a40e2e2b3292a937c", + "sha256:0b899ee80920bb533f26581af9b4660bc12aff4562555afe74e429101ebf3c94", + "sha256:12f29d6c23424f704c66b5b68c02fe0b571504459605cfe36ab8158359b0e1bb", + "sha256:135e9aa65150c53f7db85bf2bebb8a0e1a48ea850e80cf66e16dd04fa09d309c", + "sha256:153ec6f18f7b61641e0e6e502acfaf4a06c9aba2ea11c0b4b3578ea9f13a4a4a", + "sha256:17fe25efc785194d48c38fad85dce470013ba19d2fb66639e149f14bccf1327f", + "sha256:1912b7230459fd53682dae32b83cbd8e5d642ba36d4be18566f00a9c063aa13d", + "sha256:1a5b93084e01328a1cb1ecdad99d11d75e881e89a95f88d85b523646553b36c2", + "sha256:25193f934d37d836a6b1f4c062ce574a96cbca7c6d9dc8ddfbbac7f9c54deaa4", + "sha256:2c042352b430d678db50c78c5214e19638eff8b688941271da2de21fd298dfe5", + "sha256:2e818dbe445e86fc6c266973fe540c35125c42eb2cf13a6095e9adaa89c0deb5", + "sha256:2fcde9954c8882d1c7f93bb828caa34a4c5e3ee69dbc7895dc8652ad972b455a", + "sha256:35f7d998b8e82fb3fb51ff88b30485eb81cd7dd56ec7e1a8deba23eb88532d44", + "sha256:37cc0339abfa9e295c75d9a7f227d35cb44716feb95057f9449c4a9e9a17daf7", + "sha256:43334f9581cd067945b8898cef9eb5714ee4883f8de0304c011f1dbdb1d4e2aa", + "sha256:4bd4a71501b6d51db4abc07e1f43f5a6fed0a1a9583cca0b401d6af50284b0db", + "sha256:57aa6198ba8acba1313c3b743e267d821a60cac77e6026caf0b55ca58d3d23be", + "sha256:5b0d657460d9f3615876fec6306e97ca15a471f6169b622d76a47e270998acf1", + "sha256:5cd36804f9f06a914a883fe682df5711d16d7b4f44d43189c5f013e7cd91e149", + "sha256:6977cf073d83358b34f93abf5c1f1193b88675fe0e4441e0e28318bc3dcba7a0", + "sha256:718ec7a122b28d64afc5fbc3a9b99bb0545ef511373cac06fe7624520e82cb20", + "sha256:7dfbefdb3fb911ca9faed307bf309861e9995e36cca6b761c7ba6d9b77a9744a", + "sha256:801cca8923508311bf5d6d0f7da5362552e8208ebd8ec0d7b9f2cd2ff5705734", + "sha256:82b172e3264e62372c01b5b009b5b1a02fbb9276cbe5cc57ab00a6d6e5ed9a18", + "sha256:82d1ff571489765df2816785d532e243bde213752156c227fca595723ec5ff42", + "sha256:8580fc58074a16b749905b26cf8363f7b628dd167ba0130f5382cdc91c86b509", + "sha256:931030d1d6282b7900e6b0a7ff9ecdb503b5e1e6781800dab2b71a9f39405bff", + "sha256:9525cd680a6f9e80c6c0af03cf973e6505c59f60b4745f682cd1a449e54b31bb", + "sha256:a224651a81e45ef4f1d0164e256c5f6b4abb49f2ae8f22ba2f3a9d0ff338e608", + "sha256:a370d1c570f1d72e877099651e752332444b1c5009381f043c9da5fd47f3ebae", + "sha256:b1d33c63a55d0d85df0ad02b2c16158fb4d8153afa7b908f1a67330fac694cd6", + "sha256:b2240f298482f823576f397bb9f32ea913ad9456c526e141bc6f0a022b37a3e8", + "sha256:b85f703c2ffe539313e39ce0676bed0f355cec45a16e58c9ab7417445843047c", + "sha256:b9f63451084a718eccdeb1e382768c94647915653af4d6019f64560d9e98642b", + "sha256:c793dfaa130847ccff958492b76ae8b9304e60b8a79a92962cb19e368276a22b", + "sha256:d60c1625b108432ace8b1fa1a584017e5efa73f107d0f493c7f39c79bebf1d41", + "sha256:dc4b018d5c9b636f7546583c5591b9ea00c328c3e5871992ef5b95bac353f097", + "sha256:ddd16ab250b4fc97db1c47407e78c25216a75c29d29d10ad37e51b7a2ec7b2c3", + "sha256:e126ff4fed71e78333840c07279e1617f63cfca76d63ad5b27d65a7277206a3d", + "sha256:f8d49be8c282df8d2e1ab6ab53ab8abd859b1fa6fed384457ee85c9eff64ef97", + "sha256:fcf64c91fd44485100a2965d23bb0e227d093e91f7e776c5ca3b32574766eb56" + ], + "index": "pypi", + "version": "==5.0.0" + }, "pycares": { "hashes": [ "sha256:0e81c971236bb0767354f1456e67ab6ae305f248565ce77cd413a311f9572bf5", @@ -120,6 +243,55 @@ ], "version": "==2.3.0" }, + "pycparser": { + "hashes": [ + "sha256:99a8ca03e29851d96616ad0404b4aad7d9ee16f25c9f9708a11faf2810f7b226" + ], + "version": "==2.18" + }, + "pymarkovchain": { + "hashes": [ + "sha256:f8a03fba5d9f390b528991f5e3411188b545aa16a7131cea44c5f9cf1814d08a" + ], + "index": "pypi", + "version": "==1.8" + }, + "pynacl": { + "hashes": [ + "sha256:04e30e5bdeeb2d5b34107f28cd2f5bbfdc6c616f3be88fc6f53582ff1669eeca", + "sha256:0bfa0d94d2be6874e40f896e0a67e290749151e7de767c5aefbad1121cad7512", + "sha256:11aa4e141b2456ce5cecc19c130e970793fa3a2c2e6fbb8ad65b28f35aa9e6b6", + "sha256:13bdc1fe084ff9ac7653ae5a924cae03bf4bb07c6667c9eb5b6eb3c570220776", + "sha256:14339dc233e7a9dda80a3800e64e7ff89d0878ba23360eea24f1af1b13772cac", + "sha256:1d33e775fab3f383167afb20b9927aaf4961b953d76eeb271a5703a6d756b65b", + "sha256:2a42b2399d0428619e58dac7734838102d35f6dcdee149e0088823629bf99fbb", + "sha256:2dce05ac8b3c37b9e2f65eab56c544885607394753e9613fd159d5e2045c2d98", + "sha256:6453b0dae593163ffc6db6f9c9c1597d35c650598e2c39c0590d1757207a1ac2", + "sha256:73a5a96fb5fbf2215beee2353a128d382dbca83f5341f0d3c750877a236569ef", + "sha256:8abb4ef79161a5f58848b30ab6fb98d8c466da21fdd65558ce1d7afc02c70b5f", + "sha256:8ac1167195b32a8755de06efd5b2d2fe76fc864517dab66aaf65662cc59e1988", + "sha256:8f505f42f659012794414fa57c498404e64db78f1d98dfd40e318c569f3c783b", + "sha256:be71cd5fce04061e1f3d39597f93619c80cdd3558a6c9ba99a546f144a8d8101", + "sha256:cf6877124ae6a0698404e169b3ba534542cfbc43f939d46b927d956daf0a373a", + "sha256:d0eb5b2795b7ee2cbcfcadacbe95a13afbda048a262bd369da9904fecb568975", + "sha256:d795f506bcc9463efb5ebb0f65ed77921dcc9e0a50499dedd89f208445de9ecb", + "sha256:d8aaf7e5d6b0e0ef7d6dbf7abeb75085713d0100b4eb1a4e4e857de76d77ac45", + "sha256:e0d38fa0a75f65f556fb912f2c6790d1fa29b7dd27a1d9cc5591b281321eaaa9", + "sha256:eb2acabbd487a46b38540a819ef67e477a674481f84a82a7ba2234b9ba46f752", + "sha256:eeee629828d0eb4f6d98ac41e9a3a6461d114d1d0aa111a8931c049359298da0", + "sha256:f5ce9e26d25eb0b2d96f3ef0ad70e1d3ae89b5d60255c462252a3e456a48c053", + "sha256:fabf73d5d0286f9e078774f3435601d2735c94ce9e514ac4fb945701edead7e4" + ], + "index": "pypi", + "version": "==1.2.1" + }, + "six": { + "hashes": [ + "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", + "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb" + ], + "version": "==1.11.0" + }, "websockets": { "hashes": [ "sha256:0c31bc832d529dc7583d324eb6c836a4f362032a1902723c112cf57883488d8c", @@ -282,10 +454,10 @@ }, "gitpython": { "hashes": [ - "sha256:ad61bc25deadb535b047684d06f3654c001d9415e1971e51c9c20f5b510076e9", - "sha256:b8367c432de995dc330b5b146c5bfdc0926b8496e100fda6692134e00c0dcdc5" + "sha256:05069e26177c650b3cb945dd543a7ef7ca449f8db5b73038b465105673c1ef61", + "sha256:c47cc31af6e88979c57a33962cbc30a7c25508d74a1b3a19ec5aa7ed64b03129" ], - "version": "==2.1.8" + "version": "==2.1.9" }, "idna": { "hashes": [ diff --git a/README.md b/README.md index 76c385a4..a6dc0df7 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,72 @@ -# Code Jam 1 +# snek it up -This is the repository for all code relating to our first code jam, in March 2018. Participants should fork this repository, and submit their code in a pull request. +**[Python-Discord code jam 1](https://github.com/discord-python/code-jam-1) entry by Momo and kel/qrie** (Team 23) -**This code jam runs from the 23rd of March to the 25th of March, measured using the UTC timezone.** Make sure you open your pull request by then. Once the deadline is up, stop pushing commits - we will not accept any submissions made after this date. -## How To Participate +## what is it -First things first - set up your repository. Read [this guide on our site](https://pythondiscord.com/info/jams) for information on how to set yourself up for a code jam. -Remember, only one teammate needs to fork the repository - everyone else should be granted access to that fork as a contributor, so that they can work on it directly. +A Discord bot that: -Make sure you have the following things installed: +- Finds snek species on the [ITIS database](https://itis.gov/) 🐍 +- Hosts Snakes and Ladders games in concurrent channels 🎲 +- Imitates your chat history, but with 105% more snekin 💬 +- Lets you hatch snek eggs for your very own snek collection ≧◡≦ +- Draws random sneks using Perlin noise 🖌️ +- Plays snek rattles in your voice channel, on demand 🔊 -* Python 3.6 or later (installed with the PATH option enabled if you're on Windows) -* Pip - make sure you can run `pip` in a terminal or command prompt -* Pipenv - you can install this by running `pip install pipenv` in a terminal or command prompt - * Like before, make sure you can run `pipenv` in a terminal or command prompt +## how u do that -Next up, set up your project with `pipenv`. We've [compiled some documentation](./doc) for you to read over if you get stuck - you can find it in the `doc/` folder, -and you absolutely should read all of it, and it will likely answer some of the questions that you have. +- Snek lookup: `bot.snakes.get('snek name here')`, use `bot.snakes.get` for a random snek type +- Snakes and Ladders: + - Create a game using `bot.sal create` (the author can cancel the game using `bot.sal cancel`) + - Others join the game using `bot.sal join` (players can leave using `bot.sal leave`) + - The author starts the match using `bot.sal start` + - When a round begins, players use `bot.roll` to roll the dice + - glhf -Use `pipenv run run.py` to start your project. You can press `CTRL+C` with the bot window selected to stop it. +- Snake imitation: `bot.snakes.snakeme` +- Egg hatching: `bot.snakes.hatch` +- Snek drawing: `bot.snakes.draw` +- Rattle-up your voice channel: `bot.snakes.rattle` -Remember, if you need help, you can always ask on the server! +## environment variables -## The Task +You will need these environment variables to setup mr bot: -This month's theme is: **Snakes**. +- `BOT_TOKEN`: The Discord API token for the bot. +- `FFMPEG`: A direct path to a `ffmpeg` executable. If not provided, it will assume the `ffmpeg` command is in your path. +- `LIBOPUS` The name of the `libopus` library file, located in the project folder. If not provided, defaults to `libopus`. + - ffmpeg and libopus are only used for snek rattling (voice comms) -For this code jam, your task will be to create a Snake cog for a [Discord.py rewrite bot](https://github.com/Rapptz/discord.py/tree/rewrite). -You can find the [documentation for Discord.py rewrite here](https://discordpy.readthedocs.io/en/rewrite/). The best cog commands will be -added to the official Python Discord bot and made available to everyone on the server. The overall best cog will be awarded custom Code Jam -Champion roles, but the best commands from the teams who did not win will also be added to our bot, and any users who write something that -ends up in the bot will be awarded Contributor roles on the server. +## random snek database -We have prepared some Discord.py rewrite boilerplate for you in this repo. Fork the repo and work in the file called **snakes.py**, in **bot/cogs**. +In order to be able to find random snakes, you need to use the provided tool to fetch a list of snake names. That list is stored inside a pickle-file that has to be in the project directory when starting the bot. -This means you won't have to write the basic bot itself, you'll just have to write the stuff that goes in the cog. For those of you with no -discord.py experience, cogs are like little modules that the bot can load, and contain a class with methods that are hooked up to bot commands -(like **bot.tags.get**). That way, when you type `bot.snakes.get('python')`, it will run the method inside the cog that corresponds to this command. +To generate the pickle-file, you use the `tools/snekfetcher.py` file inside the Pipenv shell: -Your initial task will be to write **get_snek**. This is the minimum requirement for this contest, and everyone must do it. **get_snek** will be a -method that goes online and fetches information about a snake. If you run it without providing an argument, it should fetch information about a -random snake, including the name of the snake, a picture of the snake, and various information about it. Is it venomous? Where can it be found? -What information you choose to get is up to you. +``` +pipenv run tools\snekfetcher.py +``` -`get_snek()` should also take an optional argument `name`, which should be a string that contains the name of a snake. For example, if you do -`get_snek('cobra')`, it should get information about a cobra. `name` should be case insensitive. +The output file will be `sneks.pickle`. -If `get_snek('Python')` is called, the method should instead return information about the programming language, but making sure to return the -same type of information as for all the other snakes. Fill in this information in any way you want, try to have some fun with it. +## note about libopus -The information should be returned as a dictionary, so that other methods in the Snake class can call it and make use of it. +The `libopus.dll` is compiled for 64-bit Windows only. If you're using a different OS/architecture, you will need to find/compile the library for your system. If the name of the file changes, you will need to provide the `LIBOPUS` env variable, as described above. -Once you have finished `get_snek()`, you should make at least two bot commands. The first command, `get()`, should simply call `get_snek()` -with whatever arguments the user provided, and then make a nice embed that it returns to Discord. For example, if the user in the Discord -channel says `bot.snakes.get('anaconda')`, the bot should post an embed that shows a picture of an anaconda and some information about the -snake. +## extra configuration -The second command is entirely up to you. You can choose to use `get_snek` for this command as well, or you can come up with something entirely -different. The only requirement is that it is snake related in some way or other. Here is your chance to be creative. It is these commands that -will win or lose you this code jam. The best original ideas for these commands will probably walk away with the victory. +#### snakes and ladders -You are allowed to make as many additional commands as you want, but try to keep it a reasonable amount. The team that writes the most commands is -not automatically going to win. One really excellent command is much better than 10 mediocre ones. +It is possible to configure Snakes and Ladders to use your own board. Simply overwrite the `res/ladders/board.jpg` file and adjust the `res/ladders/board.py` file accordingly: ---- + - `BOARD_TILE_SIZE`: The size (width/height) of each tile on the board, in pixels + - `BOARD_PLAYER_SIZE`: The size of each player icon on the board, in pixels + - `BOARD_MARGIN`: Tuple (x, y) for extra margins on the board, relative to top-left corner + - `PLAYER_ICON_IMAGE_SIZE`: The size of the user avatars to request from Discord. This should be a power of 2 (e.g. `32`, `64`, `128`...) and should be higher or equal to `BOARD_PLAYER_SIZE`. + - `MAX_PLAYERS`: The maximum amount of players this board can support (i.e. how many players can you fit in each tile, at maximum capacity) + - `BOARD`: Dictionary[int, int] that defines the "shortcuts" in the board (the snakes and the ladders), in the form `from: to`. -Have fun, and don't be afraid to ask for help in the usual places if you need it! +#### rattles + +To change the rattle sounds, put audio files in the `res/rattle` directory and modify the `RATTLES` list inside `res/rattle/rattleconfig.py`. diff --git a/bot/cogs/snakes.py b/bot/cogs/snakes.py index c9ed8042..3bbaecf9 100644 --- a/bot/cogs/snakes.py +++ b/bot/cogs/snakes.py @@ -1,11 +1,50 @@ # coding=utf-8 +import asyncio +import io import logging -from typing import Any, Dict +import math +import os +import pickle +import random +from typing import Dict -from discord.ext.commands import AutoShardedBot, Context, command +from PIL import Image +from PIL.ImageDraw import ImageDraw + +import discord +from discord.ext.commands import AutoShardedBot, Context, command, group + +from pymarkovchain import MarkovChain + +import res.snakes.common_snakes +from res.rattle.rattleconfig import RATTLES + +from bot.sneks import perlin +from bot.sneks.hatching import hatching, hatching_snakes +from bot.sneks.sal import SnakeAndLaddersGame +from bot.sneks.sneks import Embeddable, SnakeDef, scrape_itis, snakify log = logging.getLogger(__name__) +# the python snek +SNEK_PYTHON = SnakeDef( + common_name="Python", + species="Pseudo lingua", + image_url="https://momoperes.ca/files/pythonpls.png", + family="sneks-that-byte", + genus="\"Programming Language\"", + short_description="python is a language that you learn because tensorflow has an API for it", + wiki_link="https://en.wikipedia.org/wiki/Pseudocode" +) + +# consolation snek :( +SNEK_SAD = discord.Embed() +SNEK_SAD.title = "sad snek :(" +SNEK_SAD.set_image(url="https://momoperes.ca/files/sadsnek.jpeg") + +# max messages to train on per user +MSG_MAX = 100 + class Snakes: """ @@ -15,33 +54,320 @@ class Snakes: def __init__(self, bot: AutoShardedBot): self.bot = bot - async def get_snek(self, name: str = None) -> Dict[str, Any]: - """ - Go online and fetch information about a snake + # libopus + libopus = os.environ.get('LIBOPUS') + if libopus is None: + libopus = "libopus" + discord.opus.load_opus(libopus) - The information includes the name of the snake, a picture of the snake, and various other pieces of info. - What information you get for the snake is up to you. Be creative! + # ffmpeg + self.ffmpeg_executable = os.environ.get('FFMPEG') + if self.ffmpeg_executable is None: + self.ffmpeg_executable = 'ffmpeg' - If "python" is given as the snake name, you should return information about the programming language, but with - all the information you'd provide for a real snake. Try to have some fun with this! + # snakes and ladders + self.active_sal: Dict[discord.TextChannel, SnakeAndLaddersGame] = {} - :param name: Optional, the name of the snake to get information for - omit for a random snake - :return: A dict containing information on a snake + # check if the snake list pickle-file exists + pickle_file_path = 'sneks.pickle' + if not os.path.isfile(pickle_file_path): + log.warning("No \'sneks.pickle\' file could be found, random snakes are disabled!") + self.snake_list = [] + else: + # load pickle + with open(pickle_file_path, 'rb') as data: + self.snake_list = pickle.load(data) + + async def get_snek(self, name: str = None) -> Embeddable: + """ + Gets information about a snek + :param name: the name of the snek + :return: snek """ + if name is not None and name.lower() == "python": + # return info about language + return SNEK_PYTHON + + if name is None: + # check if the pickle file is there + if len(self.snake_list) is 0: + return None + # random snake + name = random.choice(self.snake_list).lower() + + if name is not None: + if name.lower() in res.snakes.common_snakes.REWRITES: + name = res.snakes.common_snakes.REWRITES[name.lower()] + return await scrape_itis(name.lower()) - @command() + @command(name="snakes.get()", aliases=["snakes.get"]) async def get(self, ctx: Context, name: str = None): """ - Go online and fetch information about a snake + Get info about a snek! + """ + # fetch data for a snek + await ctx.send("Fetching data for " + name + "..." if name is not None else "Finding a random snek!") + data = await self.get_snek(name) + if data is None: + await ctx.send("sssorry I can't find that snek :(", embed=SNEK_SAD) + return + channel: discord.TextChannel = ctx.channel + embed = data.as_embed() + log.debug("Sending embed: " + str(data.__dict__)) + await channel.send(embed=embed) + + @command(name="snakes.draw()", aliases=["snakes.draw"]) + async def draw(self, ctx: Context): + """ + Draws a random snek using Perlin noise + """ + stream = self.generate_snake_image() + file = discord.File(stream, filename='snek.png') + await ctx.send(file=file) + + @command(name="snakes.rattle()", aliases=["snakes.rattle"]) + async def rattle(self, ctx: Context): + """ + Play a snake rattle in your voice channel + """ + author: discord.Member = ctx.author + if author.voice is None or author.voice.channel is None: + await ctx.send(author.mention + " You are not in a voice channel!") + return + try: + voice_channel = author.voice.channel + voice_client: discord.VoiceClient = await voice_channel.connect() + # select random rattle + rattle = os.path.join('res', 'rattle', random.choice(RATTLES)) + source = discord.FFmpegPCMAudio( + rattle, + executable=self.ffmpeg_executable + ) + # plays the sound, then dispatches the end_voice event to close the voice client + voice_client.play(source, after=lambda x: self.bot.dispatch("end_voice", voice_client)) + + except discord.ClientException as e: + log.error(e) + return + + # event handler for voice client termination + async def on_end_voice(self, voice_client): + await voice_client.disconnect() + + @group() + async def sal(self, ctx: Context): + """ + Command group for Snakes and Ladders + + - Create a S&L game: sal create + - Join a S&L game: sal join + - Leave a S&L game: sal leave + - Cancel a S&L game (author): sal cancel + - Start a S&L game (author): sal start + - Roll the dice: sal roll OR roll + """ + if ctx.invoked_subcommand is None: + # alias for 'sal roll' -> roll() + if ctx.subcommand_passed is not None and ctx.subcommand_passed.lower() == "roll": + await self.bot.get_command("roll()").invoke(ctx) + return + await ctx.send(ctx.author.mention + ": Unknown S&L command.") + + @sal.command(name="create()", aliases=["create"]) + async def create_sal(self, ctx: Context): + """ + Create a Snakes and Ladders in the channel. + """ + # check if there is already a game in this channel + channel: discord.TextChannel = ctx.channel + if channel in self.active_sal: + await ctx.send(ctx.author.mention + " A game is already in progress in this channel.") + return + game = SnakeAndLaddersGame(snakes=self, channel=channel, author=ctx.author) + self.active_sal[channel] = game + await game.open_game() + + @sal.command(name="join()", aliases=["join"]) + async def join_sal(self, ctx: Context): + """ + Join a Snakes and Ladders game in the channel. + """ + channel: discord.TextChannel = ctx.channel + if channel not in self.active_sal: + await ctx.send(ctx.author.mention + " There is no active Snakes & Ladders game in this channel.") + return + game = self.active_sal[channel] + await game.player_join(ctx.author) + + @sal.command(name="leave()", aliases=["leave", "quit"]) + async def leave_sal(self, ctx: Context): + """ + Leave the Snakes and Ladders game. + """ + channel: discord.TextChannel = ctx.channel + if channel not in self.active_sal: + await ctx.send(ctx.author.mention + " There is no active Snakes & Ladders game in this channel.") + return + game = self.active_sal[channel] + await game.player_leave(ctx.author) + + @sal.command(name="cancel()", aliases=["cancel"]) + async def cancel_sal(self, ctx: Context): + """ + Cancel the Snakes and Ladders game (author only). + """ + channel: discord.TextChannel = ctx.channel + if channel not in self.active_sal: + await ctx.send(ctx.author.mention + " There is no active Snakes & Ladders game in this channel.") + return + game = self.active_sal[channel] + await game.cancel_game(ctx.author) + + @sal.command(name="start()", aliases=["start"]) + async def start_sal(self, ctx: Context): + """ + Start the Snakes and Ladders game (author only). + """ + channel: discord.TextChannel = ctx.channel + if channel not in self.active_sal: + await ctx.send(ctx.author.mention + " There is no active Snakes & Ladders game in this channel.") + return + game = self.active_sal[channel] + await game.start_game(ctx.author) + + @command(name="roll()", aliases=["sal roll", "roll"]) + async def roll_sal(self, ctx: Context): + """ + Roll the dice in Snakes and Ladders. + """ + channel: discord.TextChannel = ctx.channel + if channel not in self.active_sal: + await ctx.send(ctx.author.mention + " There is no active Snakes & Ladders game in this channel.") + return + game = self.active_sal[channel] + await game.player_roll(ctx.author) + + @command(name="snakes.snakeme()", aliases=["snakes.snakeme", "snakeme"]) + async def snakeme(self, ctx: Context): + """ + How would I talk if I were a snake? + :param ctx: context + :return: you, snakified based on your Discord message history + """ + mentions = list(filter(lambda m: m.id != self.bot.user.id, ctx.message.mentions)) + author = ctx.message.author if (len(mentions) == 0) else ctx.message.mentions[0] + channel: discord.TextChannel = ctx.channel + + channels = [channel for channel in ctx.message.guild.channels if isinstance(channel, discord.TextChannel)] + channels_messages = [await channel.history(limit=10000).flatten() for channel in channels] + msgs = [msg for channel_messages in channels_messages for msg in channel_messages][:MSG_MAX] + + my_msgs = list(filter(lambda msg: msg.author.id == author.id, msgs)) + my_msgs_content = "\n".join(list(map(lambda x: x.content, my_msgs))) + + mc = MarkovChain() + mc.generateDatabase(my_msgs_content) + sentence = mc.generateString() - This should make use of your `get_snek` method, using it to get information about a snake. This information - should be sent back to Discord in an embed. + snakeme = discord.Embed() + snakeme.set_author(name="{0}#{1}".format(author.name, author.discriminator), + icon_url="https://cdn.discordapp.com/avatars/{0}/{1}".format( + author.id, author.avatar) if author.avatar is not None else + "https://img00.deviantart.net/eee3/i/2017/168/3/4/" + "discord__app__avatar_rev1_by_nodeviantarthere-dbd2tp9.png") + snakeme.description = "*{0}*".format( + snakify(sentence) if sentence is not None else ":question: Not enough messages") + await channel.send(embed=snakeme) - :param ctx: Context object passed from discord.py - :param name: Optional, the name of the snake to get information for - omit for a random snake + @command(name="snakes.hatch()", aliases=["snakes.hatch", "hatch"]) + async def hatch(self, ctx: Context): """ + Hatches your personal snake + :param ctx: context + :return: baby snake + """ + channel: discord.TextChannel = ctx.channel + + my_snake = list(hatching_snakes.keys())[random.randint(0, 3)] + my_snake_img = hatching_snakes[my_snake] + log.debug(my_snake_img) + + m = await channel.send(embed=discord.Embed(description="Hatching your snake :snake:...")) + await asyncio.sleep(1) + + for i in range(len(hatching)): + hatch_embed = discord.Embed(description=hatching[i]) + await m.edit(embed=hatch_embed) + await asyncio.sleep(1) + # await m.edit(embed = discord.Embed().set_thumbnail(url="https://i.imgur.com/5QHH4If.jpg")) + await asyncio.sleep(1) + await m.delete() + + my_snake_embed = discord.Embed(description=":tada: Congrats! You hatched: **{0}**".format(my_snake)) + my_snake_embed.set_thumbnail(url=my_snake_img) + my_snake_embed.set_footer( + text=" Owner: {0}#{1}".format(ctx.message.author.name, ctx.message.author.discriminator)) + await channel.send(embed=my_snake_embed) + + def generate_snake_image(self) -> bytes: + """ + Generate a CGI snek using perlin noise + :return: the binary data of the PNG image + """ + fac = perlin.PerlinNoiseFactory(dimension=1, octaves=2) + img_size = 200 + margins = 50 + start_x = random.randint(margins, img_size - margins) + start_y = random.randint(margins, img_size - margins) + points = [(start_x, start_y)] + snake_length = 12 + snake_color = 0x15c7ea + text_color = 0xf2ea15 + background_color = 0x0 + + for i in range(0, snake_length): + angle = math.radians(fac.get_plain_noise((1 / (snake_length + 1)) * (i + 1)) * 360) + curr_point = points[i] + segment_length = random.randint(15, 20) + next_x = curr_point[0] + segment_length * math.cos(angle) + next_y = curr_point[1] + segment_length * math.sin(angle) + points.append((next_x, next_y)) + + # normalize bounds + min_dimensions = [start_x, start_y] + max_dimensions = [start_x, start_y] + for p in points: + if p[0] < min_dimensions[0]: + min_dimensions[0] = p[0] + if p[0] > max_dimensions[0]: + max_dimensions[0] = p[0] + if p[1] < min_dimensions[1]: + min_dimensions[1] = p[1] + if p[1] > max_dimensions[1]: + max_dimensions[1] = p[1] + + # shift towards middle + dimension_range = (max_dimensions[0] - min_dimensions[0], max_dimensions[1] - min_dimensions[1]) + shift = ( + img_size / 2 - (dimension_range[0] / 2 + min_dimensions[0]), + img_size / 2 - (dimension_range[1] / 2 + min_dimensions[1]) + ) - # Any additional commands can be placed here. Be creative, but keep it to a reasonable amount! + img = Image.new(mode='RGB', size=(img_size, img_size), color=background_color) + draw = ImageDraw(img) + for i in range(1, len(points)): + p = points[i] + prev = points[i - 1] + draw.line( + (shift[0] + prev[0], shift[1] + prev[1], shift[0] + p[0], shift[1] + p[1]), + width=8, + fill=snake_color + ) + draw.multiline_text((img_size - margins, img_size - margins), text="snek\nit\nup", fill=text_color) + del draw + stream = io.BytesIO() + img.save(stream, format='PNG') + return stream.getvalue() def setup(bot): diff --git a/bot/sneks/__init__.py b/bot/sneks/__init__.py new file mode 100644 index 00000000..9bad5790 --- /dev/null +++ b/bot/sneks/__init__.py @@ -0,0 +1 @@ +# coding=utf-8 diff --git a/bot/sneks/hatching.py b/bot/sneks/hatching.py new file mode 100644 index 00000000..aa15772c --- /dev/null +++ b/bot/sneks/hatching.py @@ -0,0 +1,44 @@ +h1 = '''``` + ---- + ------ + /--------\\ + |--------| + |--------| + \------/ + ----```''' + +h2 = '''``` + ---- + ------ + /---\\-/--\\ + |-----\\--| + |--------| + \------/ + ----```''' + +h3 = '''``` + ---- + ------ + /---\\-/--\\ + |-----\\--| + |-----/--| + \----\\-/ + ----```''' + +h4 = '''``` + ----- + ----- \\ + /--| /---\\ + |--\\ -\\---| + |--\\--/-- / + \------- / + ------```''' + +hatching = [h1, h2, h3, h4] +hatching_snakes = { + "Baby Python": "https://i.imgur.com/SYOcmSa.png", + "Baby Rattle Snake": "https://i.imgur.com/i5jYA8f.png", + "Baby Dragon Snake": "https://i.imgur.com/SuMKM4m.png", + "Baby Garden Snake": "https://i.imgur.com/5vYx3ah.png", + "Baby Cobra": "https://i.imgur.com/jk14ryt.png" +} diff --git a/bot/sneks/perlin.py b/bot/sneks/perlin.py new file mode 100644 index 00000000..19eafe6a --- /dev/null +++ b/bot/sneks/perlin.py @@ -0,0 +1,158 @@ +""" +Perlin noise implementation. +Taken from: https://gist.github.com/eevee/26f547457522755cb1fb8739d0ea89a1 +Licensed under ISC +""" +import math +import random +# Licensed under ISC +from itertools import product + + +def smoothstep(t): + """Smooth curve with a zero derivative at 0 and 1, making it useful for + interpolating. + """ + return t * t * (3. - 2. * t) + + +def lerp(t, a, b): + """Linear interpolation between a and b, given a fraction t.""" + return a + t * (b - a) + + +class PerlinNoiseFactory(object): + """Callable that produces Perlin noise for an arbitrary point in an + arbitrary number of dimensions. The underlying grid is aligned with the + integers. + There is no limit to the coordinates used; new gradients are generated on + the fly as necessary. + """ + + def __init__(self, dimension, octaves=1, tile=(), unbias=False): + """Create a new Perlin noise factory in the given number of dimensions, + which should be an integer and at least 1. + More octaves create a foggier and more-detailed noise pattern. More + than 4 octaves is rather excessive. + ``tile`` can be used to make a seamlessly tiling pattern. For example: + pnf = PerlinNoiseFactory(2, tile=(0, 3)) + This will produce noise that tiles every 3 units vertically, but never + tiles horizontally. + If ``unbias`` is true, the smoothstep function will be applied to the + output before returning it, to counteract some of Perlin noise's + significant bias towards the center of its output range. + """ + self.dimension = dimension + self.octaves = octaves + self.tile = tile + (0,) * dimension + self.unbias = unbias + + # For n dimensions, the range of Perlin noise is ±sqrt(n)/2; multiply + # by this to scale to ±1 + self.scale_factor = 2 * dimension ** -0.5 + + self.gradient = {} + + def _generate_gradient(self): + # Generate a random unit vector at each grid point -- this is the + # "gradient" vector, in that the grid tile slopes towards it + + # 1 dimension is special, since the only unit vector is trivial; + # instead, use a slope between -1 and 1 + if self.dimension == 1: + return (random.uniform(-1, 1),) + + # Generate a random point on the surface of the unit n-hypersphere; + # this is the same as a random unit vector in n dimensions. Thanks + # to: http://mathworld.wolfram.com/SpherePointPicking.html + # Pick n normal random variables with stddev 1 + random_point = [random.gauss(0, 1) for _ in range(self.dimension)] + # Then scale the result to a unit vector + scale = sum(n * n for n in random_point) ** -0.5 + return tuple(coord * scale for coord in random_point) + + def get_plain_noise(self, *point): + """Get plain noise for a single point, without taking into account + either octaves or tiling. + """ + if len(point) != self.dimension: + raise ValueError("Expected {0} values, got {1}".format( + self.dimension, len(point))) + + # Build a list of the (min, max) bounds in each dimension + grid_coords = [] + for coord in point: + min_coord = math.floor(coord) + max_coord = min_coord + 1 + grid_coords.append((min_coord, max_coord)) + + # Compute the dot product of each gradient vector and the point's + # distance from the corresponding grid point. This gives you each + # gradient's "influence" on the chosen point. + dots = [] + for grid_point in product(*grid_coords): + if grid_point not in self.gradient: + self.gradient[grid_point] = self._generate_gradient() + gradient = self.gradient[grid_point] + + dot = 0 + for i in range(self.dimension): + dot += gradient[i] * (point[i] - grid_point[i]) + dots.append(dot) + + # Interpolate all those dot products together. The interpolation is + # done with smoothstep to smooth out the slope as you pass from one + # grid cell into the next. + # Due to the way product() works, dot products are ordered such that + # the last dimension alternates: (..., min), (..., max), etc. So we + # can interpolate adjacent pairs to "collapse" that last dimension. Then + # the results will alternate in their second-to-last dimension, and so + # forth, until we only have a single value left. + dim = self.dimension + while len(dots) > 1: + dim -= 1 + s = smoothstep(point[dim] - grid_coords[dim][0]) + + next_dots = [] + while dots: + next_dots.append(lerp(s, dots.pop(0), dots.pop(0))) + + dots = next_dots + + return dots[0] * self.scale_factor + + def __call__(self, *point): + """Get the value of this Perlin noise function at the given point. The + number of values given should match the number of dimensions. + """ + ret = 0 + for o in range(self.octaves): + o2 = 1 << o + new_point = [] + for i, coord in enumerate(point): + coord *= o2 + if self.tile[i]: + coord %= self.tile[i] * o2 + new_point.append(coord) + ret += self.get_plain_noise(*new_point) / o2 + + # Need to scale n back down since adding all those extra octaves has + # probably expanded it beyond ±1 + # 1 octave: ±1 + # 2 octaves: ±1½ + # 3 octaves: ±1¾ + ret /= 2 - 2 ** (1 - self.octaves) + + if self.unbias: + # The output of the plain Perlin noise algorithm has a fairly + # strong bias towards the center due to the central limit theorem + # -- in fact the top and bottom 1/8 virtually never happen. That's + # a quarter of our entire output range! If only we had a function + # in [0..1] that could introduce a bias towards the endpoints... + r = (ret + 1) / 2 + # Doing it this many times is a completely made-up heuristic. + for _ in range(int(self.octaves / 2 + 0.5)): + r = smoothstep(r) + ret = r * 2 - 1 + + return ret diff --git a/bot/sneks/sal.py b/bot/sneks/sal.py new file mode 100644 index 00000000..0b7e8496 --- /dev/null +++ b/bot/sneks/sal.py @@ -0,0 +1,175 @@ +import io +import math +import os +import random +from typing import Dict, List + +from PIL import Image + +import aiohttp + +import discord + +from res.ladders.board import BOARD, BOARD_MARGIN, BOARD_PLAYER_SIZE, BOARD_TILE_SIZE, MAX_PLAYERS, \ + PLAYER_ICON_IMAGE_SIZE + + +class SnakeAndLaddersGame: + def __init__(self, snakes, channel: discord.TextChannel, author: discord.Member): + self.snakes = snakes + self.channel = channel + self.state = 'booting' + self.author = author + self.players: List[discord.Member] = [] + self.player_tiles: Dict[int, int] = {} + self.round_has_rolled: Dict[int, bool] = {} + self.avatar_images: Dict[int, Image] = {} + + async def open_game(self): + await self._add_player(self.author) + await self.channel.send( + '**Snakes and Ladders**: A new game is about to start!\nMention me and type **sal join** to participate.', + file=discord.File(os.path.join('res', 'ladders', 'banner.jpg'), filename='Snakes and Ladders.jpg')) + self.state = 'waiting' + + async def _add_player(self, user: discord.Member): + self.players.append(user) + self.player_tiles[user.id] = 1 + avatar_url = user.avatar_url_as(format='jpeg', size=PLAYER_ICON_IMAGE_SIZE) + async with aiohttp.ClientSession() as session: + async with session.get(avatar_url) as res: + avatar_bytes = await res.read() + im = Image.open(io.BytesIO(avatar_bytes)).resize((BOARD_PLAYER_SIZE, BOARD_PLAYER_SIZE)) + self.avatar_images[user.id] = im + + async def player_join(self, user: discord.Member): + for p in self.players: + if user == p: + await self.channel.send(user.mention + " You are already in the game.") + return + if self.state != 'waiting': + await self.channel.send(user.mention + " You cannot join at this time.") + return + if len(self.players) is MAX_PLAYERS: + await self.channel.send(user.mention + " The game is full!") + return + + await self._add_player(user) + + await self.channel.send( + "**Snakes and Ladders**: " + user.mention + " has joined the game.\nThere are now " + str( + len(self.players)) + " players in the game.") + + async def player_leave(self, user: discord.Member): + if user == self.author: + await self.channel.send(user.mention + " You are the author, and cannot leave the game. Execute " + "`sal cancel` to cancel the game.") + return + for p in self.players: + if user == p: + self.players.remove(p) + self.player_tiles.pop(p.id, None) + self.round_has_rolled.pop(p.id, None) + await self.channel.send("**Snakes and Ladders**: " + user.mention + " has left the game.") + if self.state != 'waiting' and len(self.players) == 1: + await self.channel.send("**Snakes and Ladders**: The game has been surrendered!") + self._destruct() + return + await self.channel.send(user.mention + " You are not in the match.") + + async def cancel_game(self, user: discord.Member): + if not user == self.author: + await self.channel.send(user.mention + " Only the author of the game can cancel it.") + return + await self.channel.send("**Snakes and Ladders**: Game has been canceled.") + self._destruct() + + async def start_game(self, user: discord.Member): + if not user == self.author: + await self.channel.send(user.mention + " Only the author of the game can start it.") + return + if len(self.players) < 2: + await self.channel.send(user.mention + " A minimum of 2 players is required to start the game.") + return + if not self.state == 'waiting': + await self.channel.send(user.mention + " The game cannot be started at this time.") + return + self.state = 'starting' + player_list = ', '.join(user.mention for user in self.players) + await self.channel.send("**Snakes and Ladders**: The game is starting!\nPlayers: " + player_list) + await self.start_round() + + async def start_round(self): + self.state = 'roll' + for user in self.players: + self.round_has_rolled[user.id] = False + board_img = Image.open(os.path.join('res', 'ladders', 'board.jpg')) + player_row_size = math.ceil(MAX_PLAYERS / 2) + for i, player in enumerate(self.players): + tile = self.player_tiles[player.id] + tile_coordinates = self._board_coordinate_from_index(tile) + x_offset = BOARD_MARGIN[0] + tile_coordinates[0] * BOARD_TILE_SIZE + y_offset = \ + BOARD_MARGIN[1] + ( + (10 * BOARD_TILE_SIZE) - (9 - tile_coordinates[1]) * BOARD_TILE_SIZE - BOARD_PLAYER_SIZE) + x_offset += BOARD_PLAYER_SIZE * (i % player_row_size) + y_offset -= BOARD_PLAYER_SIZE * math.floor(i / player_row_size) + board_img.paste(self.avatar_images[player.id], + box=(x_offset, y_offset)) + stream = io.BytesIO() + board_img.save(stream, format='JPEG') + board_file = discord.File(stream.getvalue(), filename='Board.jpg') + await self.channel.send("**Snakes and Ladders**: A new round has started! Current board:", file=board_file) + player_list = '\n'.join((user.mention + ": Tile " + str(self.player_tiles[user.id])) for user in self.players) + await self.channel.send( + "**Current positions**:\n" + player_list + "\n\nMention me with **roll** to roll the dice!") + + async def player_roll(self, user: discord.Member): + if user.id not in self.player_tiles: + await self.channel.send(user.mention + " You are not in the match.") + return + if self.state != 'roll': + await self.channel.send(user.mention + " You may not roll at this time.") + return + if self.round_has_rolled[user.id]: + await self.channel.send(user.mention + " You have already rolled this round, please be patient.") + return + roll = random.randint(1, 6) + await self.channel.send(user.mention + " rolled a **{0}**!".format(roll)) + next_tile = self.player_tiles[user.id] + roll + # apply snakes and ladders + if next_tile in BOARD: + target = BOARD[next_tile] + if target < next_tile: + await self.channel.send(user.mention + " slips on a snake and falls back to **{0}**".format(target)) + else: + await self.channel.send(user.mention + " climbs a ladder to **{0}**".format(target)) + next_tile = target + + self.player_tiles[user.id] = min(100, next_tile) + self.round_has_rolled[user.id] = True + winner = self._check_winner() + if winner is not None: + await self.channel.send("**Snakes and Ladders**: " + user.mention + " has won the game! :tada:") + self._destruct() + return + if self._check_all_rolled(): + await self.start_round() + + def _check_winner(self) -> discord.Member: + return next((p for p in self.players if self.player_tiles[p.id] == 100), None) + + def _check_all_rolled(self): + return all(rolled for rolled in self.round_has_rolled.values()) + + def _destruct(self): + del self.snakes.active_sal[self.channel] + + def _board_coordinate_from_index(self, index: int): + # converts the tile number to the x/y coordinates for graphical purposes + y_level = 9 - math.floor((index - 1) / 10) + is_reversed = math.floor((index - 1) / 10) % 2 != 0 + x_level = (index - 1) % 10 + if is_reversed: + x_level = 9 - x_level + return x_level, y_level diff --git a/bot/sneks/sneks.py b/bot/sneks/sneks.py new file mode 100644 index 00000000..34ba2d26 --- /dev/null +++ b/bot/sneks/sneks.py @@ -0,0 +1,295 @@ +import json +import logging +import random +from urllib import parse + +import aiohttp + +from bs4 import BeautifulSoup + +import discord + +import requests + +# the search URL for the ITIS database +ITIS_BASE_URL = "https://itis.gov/servlet/SingleRpt/{0}" +ITIS_SEARCH_URL = ITIS_BASE_URL.format("SingleRpt") +ITIS_JSON_SERVICE_FULLRECORD = "https://itis.gov/ITISWebService/jsonservice/getFullRecordFromTSN?tsn={0}" +ITIS_JSON_SERVICE_FULLHIERARCHY = "https://itis.gov/ITISWebService/jsonservice/getFullHierarchyFromTSN?tsn={0}" +WIKI_API_URL = "http://en.wikipedia.org/w/api.php?{0}" +WIKI_URL = "http://en.wikipedia.org/wiki/{0}" +IMAGE_SEARCH_URL = "https://api.qwant.com/api/search/images?count=1&offset=1&q={0}+snake" + +log = logging.getLogger(__name__) + + +class Embeddable: + """ + Represents an object that can be serialized to a :class:`discord.Embed` + """ + + def as_embed(self) -> discord.Embed: + raise NotImplementedError() + + +class SnakeDef(Embeddable): + """ + Represents a snek species + """ + + def __init__(self, common_name="", species="", image_url="", family="", genus="", short_description="", + wiki_link="", geo=""): + self.common_name = common_name + self.species = species + self.image_url = image_url + self.family = family + self.genus = genus + self.short_description = short_description + self.wiki_link = wiki_link + self.geo = geo + + def as_embed(self): + # returns a discord embed with the snek + embed = discord.Embed() + embed.title = self.species + " (" + self.common_name + ")" + embed.colour = discord.Colour.green() + embed.url = self.wiki_link + if self.family is not None and self.family != "": + embed.add_field(name="Family", value=self.family) + if self.genus is not None and self.genus != "": + embed.add_field(name="Genus", value=self.genus) + embed.add_field(name="Species", value=self.species) + embed.set_image(url=self.image_url) + embed.description = self.short_description + if len(embed.description) > 1000: + embed.description = embed.description[:997] + "..." + if self.geo != "" and self.geo is not None: + embed.add_field(name="Geography", value=self.geo) + return embed + + +class SnakeGroup(Embeddable): + + def __init__(self, common_name="None", scientific_name="None", image_url="", rank="Unknown", sub=[], + short_description="A snake group", link="", geo=""): + self.link = link + self.common_name = common_name + self.scientific_name = scientific_name + self.image_url = image_url + self.rank = rank + self.sub = sub + self.short_description = short_description + self.geo = geo + + def as_embed(self): + embed = discord.Embed() + embed.title = self.scientific_name + ( + (" (" + self.common_name + ")") if self.common_name is not None and self.common_name is not "None" else "") + embed.description = self.short_description + if len(embed.description) > 1000: + embed.description = embed.description[:997] + "..." + embed.colour = discord.Colour.green() + embed.url = self.link + embed.set_image(url=self.image_url) + if self.common_name is not "None" and self.common_name is not None: + embed.add_field(name="Common Name", value=self.common_name) + embed.add_field(name="Taxonomic Rank", value=self.rank) + if self.geo is not "": + embed.add_field(name="Geography", value=self.geo) + return embed + + +def find_image_url(name: str) -> str: + """ + Searches an image on the Qwant search engine API + :param name: the name of the image + :return: a direct URL to the image, or an empty string if the search was unsuccessful + """ + req_url = IMAGE_SEARCH_URL.format(name.replace(" ", "+")) + res = requests.get(url=req_url, headers={"User-Agent": "Mozilla/5.0"}) + if res.status_code != 200: + return "" + j = json.JSONDecoder().decode(res.content.decode("utf-8")) + image_url = j['data']['result']['items'][0]['media'] + return image_url + + +def is_itis_table_empty(soup) -> bool: + """ + Checks whether an ITIS search result table is empty + :param soup: the soup to search in + :return: true if the search table is empty + """ + return "No Records Found." in str(soup) + + +def itis_find_link(soup) -> str: + """ + Finds the first link in an ITIS search result table + :param soup: the soup to search in + :return: a direct URL + """ + return ITIS_BASE_URL.format(soup.find("a")['href']) + + +async def wiki_summary(session: aiohttp.ClientSession, name: str, deepcat: str) -> str: + """ + Finds the summary of the given Wikipedia article + :param session: the aiohttp HTTP session + :param name: the title to search + :param deepcat: (optional) category to search in, recursively + :return: + """ + search_url = WIKI_API_URL.format(parse.urlencode({ + 'list': 'search', + 'srprop': '', + 'srlimit': 1, + 'srsearch': ("deepcat:" + deepcat + " " + name) if deepcat is not None else name, + 'format': 'json', + 'action': 'query' + })) + async with session.get(search_url) as res: + j = await res.json() + log.debug(search_url) + if len(j['query']['search']) is 0: + return None + page_title = j['query']['search'][0]['title'] + page_id = str(j['query']['search'][0]['pageid']) + page_url = WIKI_API_URL.format(parse.urlencode({ + 'prop': 'extracts', + 'explaintext': '', + 'titles': page_title, + 'exsentences': '2', + 'format': 'json', + 'action': 'query' + })) + async with session.get(page_url) as page_res: + page_json = await page_res.json() + return page_json['query']['pages'][page_id]['extract'] + + +async def scrape_itis_page(url: str, initial_query: str) -> Embeddable: + """ + Scrapes an ITIS page from the direct URL + :param url: the URL of the ITIS page + :param initial_query: the initial query submitted in the search + :return: an Embeddable object to be output + """ + tsn = parse.parse_qs(parse.urlparse(url).query)['search_value'][0] + json_url = ITIS_JSON_SERVICE_FULLRECORD.format(tsn) + + async with aiohttp.ClientSession() as session: + async with session.get(json_url) as res: + j = await res.text(encoding='iso-8859-1') + data = json.JSONDecoder().decode(j) + common_names = [] + for common_name_tag in data['commonNameList']['commonNames']: + if common_name_tag is None: + continue + if common_name_tag['language'] == "English": + common_names.append(common_name_tag['commonName']) + common_name = ', '.join(common_names) + rank = data['hierarchyUp']['rankName'] + scientific_name = data['hierarchyUp']['taxonName'] + geo = [] + for geoDivisions in data['geographicDivisionList']['geoDivisions']: + if geoDivisions is not None: + geo.append(geoDivisions['geographicValue']) + if rank == "Species": + embeddable = SnakeDef() + embeddable.common_name = common_name if common_name != "" else "None" + embeddable.species = data['scientificName']['combinedName'] + embeddable.genus = data['hierarchyUp']['parentName'] + + async with session.get(ITIS_JSON_SERVICE_FULLHIERARCHY.format(tsn)) as hierarchy_res: + hier_j = await hierarchy_res.text(encoding='iso-8859-1') + hier_data = json.JSONDecoder().decode(hier_j) + family = "Unknown" + for hier in hier_data['hierarchyList']: + if hier['rankName'] == 'Family': + family = hier['taxonName'] + embeddable.family = family + + embeddable.image_url = find_image_url(scientific_name) + embeddable.wiki_link = url + summary = await wiki_summary(session, scientific_name + " " + initial_query, deepcat='Snake_genera') + embeddable.short_description = summary if not None else "" + embeddable.geo = ', '.join(geo) + else: + embeddable = SnakeGroup() + summary = await wiki_summary(session, scientific_name + " " + initial_query, deepcat='Snake_genera') + embeddable.short_description = summary if not None else "" + embeddable.common_name = common_name if common_name != "" else "None" + embeddable.scientific_name = scientific_name + embeddable.link = url + embeddable.image_url = find_image_url(scientific_name) + embeddable.rank = rank + embeddable.geo = ', '.join(geo) + return embeddable + + +async def scrape_itis(name: str) -> Embeddable: + """ + Searches and scrapes the ITIS database from the given animal name + :param name: the name of the animal + :return: an Embeddable object to be output + """ + form_data = { + 'categories': 'All', + 'Go': 'Search', + 'search_credRating': 'All', + 'search_kingdom': 'Animal', + 'search_span': 'exactly_for', + 'search_topic': 'all', + 'search_value': name, + 'source': 'html' + } + res = requests.post(url=ITIS_SEARCH_URL, data=form_data) + html = res.content.decode('iso-8859-1') + if "No Records Found?" in html: + async with aiohttp.ClientSession() as session: + # no snek, maybe wikipedia? + snake = SnakeDef() + snake.short_description = await wiki_summary(session, name, deepcat='Snakes_by_common_name') + if snake.short_description is None: + snake.short_description = await wiki_summary(session, name, deepcat='Snake_genera') + if snake.short_description is None: + return None + snake.species = name.capitalize() + snake.common_name = snake.species + snake.wiki_link = WIKI_URL.format(name.capitalize()).replace(' ', '_') + snake.image_url = find_image_url(name) + return snake + soup = BeautifulSoup(html, "html.parser") + + tables = soup.find_all("table", {"width": "100%"}) + table_common_name = tables[1] + table_scientific = tables[2] + + is_common_name = not is_itis_table_empty(table_common_name) + is_scientific = not is_itis_table_empty(table_scientific) + + if not is_common_name and not is_scientific: + # unknown snek, abort + return None + + url = None + if is_scientific: + url = itis_find_link(table_scientific) + elif is_common_name: + url = itis_find_link(table_common_name) + if url is None: + return None + + return await scrape_itis_page(url, name) + + +def snakify(s): + """ + "Snakifies" a string, by randomly elongating s's and e's + :param s: the string to "snakify" + :return: the "snakified" string + """ + x = random.randint(3, 8) + y = random.randint(3, 8) + return s.replace("s", x * "s").replace("e", y * "e") if s is not None else s diff --git a/libopus.dll b/libopus.dll new file mode 100644 index 00000000..a962869f Binary files /dev/null and b/libopus.dll differ diff --git a/res/ladders/banner.jpg b/res/ladders/banner.jpg new file mode 100644 index 00000000..69eaaf12 Binary files /dev/null and b/res/ladders/banner.jpg differ diff --git a/res/ladders/board.jpg b/res/ladders/board.jpg new file mode 100644 index 00000000..20032e39 Binary files /dev/null and b/res/ladders/board.jpg differ diff --git a/res/ladders/board.py b/res/ladders/board.py new file mode 100644 index 00000000..d197bacd --- /dev/null +++ b/res/ladders/board.py @@ -0,0 +1,33 @@ +BOARD_TILE_SIZE = 56 # the size of each board tile +BOARD_PLAYER_SIZE = 20 # the size of each player icon +BOARD_MARGIN = (10, 0) # margins, in pixels (for player icons) +PLAYER_ICON_IMAGE_SIZE = 32 # the size of the image to download, should a power of 2 and higher than BOARD_PLAYER_SIZE +MAX_PLAYERS = 4 # depends on the board size/quality, 4 is for the default board + +# board definition (from, to) +BOARD = { + # ladders + 2: 38, + 7: 14, + 8: 31, + 15: 26, + 21: 42, + 28: 84, + 36: 44, + 51: 67, + 71: 91, + 78: 98, + 87: 94, + + # snakes + 99: 80, + 95: 75, + 92: 88, + 89: 68, + 74: 53, + 64: 60, + 62: 19, + 49: 11, + 46: 25, + 16: 6 +} diff --git a/res/rattle/rattle1.mp3 b/res/rattle/rattle1.mp3 new file mode 100644 index 00000000..592615cb Binary files /dev/null and b/res/rattle/rattle1.mp3 differ diff --git a/res/rattle/rattle2.mp3 b/res/rattle/rattle2.mp3 new file mode 100644 index 00000000..e1342d14 Binary files /dev/null and b/res/rattle/rattle2.mp3 differ diff --git a/res/rattle/rattle3.mp3 b/res/rattle/rattle3.mp3 new file mode 100644 index 00000000..3053be34 Binary files /dev/null and b/res/rattle/rattle3.mp3 differ diff --git a/res/rattle/rattle4.mp3 b/res/rattle/rattle4.mp3 new file mode 100644 index 00000000..768d17a0 Binary files /dev/null and b/res/rattle/rattle4.mp3 differ diff --git a/res/rattle/rattleconfig.py b/res/rattle/rattleconfig.py new file mode 100644 index 00000000..2d65d842 --- /dev/null +++ b/res/rattle/rattleconfig.py @@ -0,0 +1,7 @@ +# list of possible files to play +RATTLES = [ + 'rattle1.mp3', + 'rattle2.mp3', + 'rattle3.mp3', + 'rattle4.mp3' +] diff --git a/res/snakes/common_snakes.py b/res/snakes/common_snakes.py new file mode 100644 index 00000000..bcd9f2cf --- /dev/null +++ b/res/snakes/common_snakes.py @@ -0,0 +1,7 @@ +# some common snake categories need to be rewritten because they aren't scientifically accurate +REWRITES = { + 'cobra': 'naja', + 'cobras': 'naja', + 'anaconda': 'anacondas', + 'viper': 'viperidae' +} diff --git a/run.py b/run.py index 2ec711fd..86a13ffa 100644 --- a/run.py +++ b/run.py @@ -16,7 +16,7 @@ ">>> ", ">> ", "> ", ">>>", ">>", ">" ), # Order matters (and so do commas) - activity=Game(name="Help: bot.help()"), + activity=Game(name="snek it up"), help_attrs={"aliases": ["help()"]}, formatter=Formatter() ) diff --git a/tools/snekfetcher.py b/tools/snekfetcher.py new file mode 100644 index 00000000..03fcdb00 --- /dev/null +++ b/tools/snekfetcher.py @@ -0,0 +1,30 @@ +# a tool to fetch some sneks +# should be run in the same pipenv as the bot (using pipenv shell) + +import asyncio +import pickle + +import aiohttp + +OUTPUT_FILE = "sneks.pickle" +FETCH_URL = "https://en.wikipedia.org/w/api.php?action=query&list=categorymembers" \ + "&cmtitle=Category:Snake_genera&cmlimit=500&cmtype=page&format=json" + + +async def fetch(session): + async with session.get(FETCH_URL) as res: + return await res.json() + + +loop = asyncio.get_event_loop() + +with aiohttp.ClientSession(loop=loop) as session: + result = loop.run_until_complete( + fetch(session) + ) + snake_names = [r['title'] for r in result['query']['categorymembers']] + with open(OUTPUT_FILE, 'wb') as out: + pickle.dump(snake_names, out) + print('done output of ' + str(len(snake_names)) + ' sneks') + +loop.close() diff --git a/tox.ini b/tox.ini index c9de86ca..317b50bb 100644 --- a/tox.ini +++ b/tox.ini @@ -2,4 +2,4 @@ max-line-length=120 application_import_names=bot exclude=.venv -ignore=B311,W503,E226 +ignore=B311,W503,E226,B403,B301