diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..fe894291 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,12 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: tsubaki_bot +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +custom: [] diff --git a/.gitignore b/.gitignore index 0b9a1654..ec76ca0a 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,7 @@ bot_data/ # 3way Merge .orig + +.DS_Store +.vscode +__pycache__ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..46077dbe --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 TsubakiBotPad + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 05dedece..1326570d 100644 --- a/README.md +++ b/README.md @@ -1,236 +1,144 @@ -# miru-v3cogs +# pad-cogs -Red v3 Cogs developed for Miru Bot. +**Important: You must use Python 3.8 for the setup!!** If you use an earlier or later version, things WILL break. It's not just convenient to be on the same version as us, it's mandatory. -Code should be pep8 formatted with a 100 character line limit. +# Setting up your own Tsubaki instance -# Cog status +## Prerequisites / Tools -### Completed and tested +- Install Git +- Install Python 3.8 +- Familiarize yourself with how to use a command prompt and Git Bash (if you can't do this you will have a bad time) -``` +[Red's Installation Guide](https://docs.discord.red/en/stable/install_guides/index.html) is a useful resource if you're a first timer. -``` +I suggest using [PyCharm Community](https://www.jetbrains.com/pycharm/download) for development. Change into the +`PycharmProjects` directory and clone your fork of the repo: -### First pass completed +## Check out the repo -``` -automod2 -azurelane -baduser -calculator -channelmod -chronomagia -dadguide -damagecalc -donations -fancysay -memes -modnotes -padboard -padbuilds -padevents -padglobal -padguidedb -padinfo -padmonitor -padrem -padsearch -padvision -profile -rpadutils -schoolidol -seniority -sqlactivitylog -stickers -streamcopy -timecog -translate -trutils -voicerole -``` +Fork this repo (you are probably already on the page, but if not [go here](https://github.com/TsubakiBotPad/pad-cogs)). -``` -# annoying -speech -``` +> Note: If you are developing a cog that is in the [misc-cogs repo](https://github.com/TsubakiBotPad/misc-cogs), you may need to complete some steps for both pad-cogs AND misc-cogs. +You will need a GitHub account to do this. Click the button that says "Fork" in the upper-right-hand corner of the page. +Git clone your forked repository. +## Create a Discord Bot +If you don't have one already, follow the instructions to create a bot in Red's official documentation: +[Creating a bot account](https://docs.discord.red/en/stable/bot_application_guide.html#creating-a-bot-account) -# Setting up your own Miru instance for contributing code +Keep the bot `token` that you get from Discord at the end of the instructions handy - you will need it to set up the bot later. -## Installing Tools +## Installation -* Install Git (see the Red install link below if you need help doing this, or read any tutorial) -* Install Python 3.8 (see the Red install link below if you need help doing this, or read any tutorial) -* Familiarize yourself with how to use a command prompt and Git Bash (if you can't do this you will have a bad time) +1. Clone this repo (fork if you're contributing) +1. Create a python 3 venv. `virtualenv -p python3 ` +1. Activate the venv. +1. `pip install -r requirements.txt` +The above steps install Red automatically. You can now follow the Red instructions to startup the bot: -## Check out the repo +[Setting Up and Running Red (macOS)](https://docs.discord.red/en/stable/install_guides/mac.html#setting-up-and-running-red) -Fork this repo (you are probably already on the page, but if not [go here](https://github.com/nachoapps/miru-v3cogs). -You will need a GitHub account to do this. Click the button that says "Fork" in the upper-right-hand corner of the page. +In command line: -I suggest using [PyCharm Community](https://www.jetbrains.com/pycharm/download) for development. Change into the -`PycharmProjects` directory and clone your fork of the repo: +- `redbot-setup` +- `redbot ` -```shell script -# Clone your repo, not this one. -git clone https://github.com/nachoapps/miru-v3cogs.git +## Configure Your Bot -# If you're just setting up a production bot, no need to do this. But you should create a directory and do: -wget https://raw.githubusercontent.com/nachoapps/miru-v3cogs/master/requirements.txt -``` +The rest of the guide takes place from inside Discord. Replace `^` with your prefix to talk to your bot. -## Set up a virtualenv +### Tell your bot where to find the code -One way to install python 3.8: +Once the bot is launched, set it to use your repo directory as a cog path. Type this in Discord where the bot is: -```shell script -sudo add-apt-repository ppa:deadsnakes/ppa -sudo apt update -sudo apt install python3.8 ``` - -Then set up your 3.8 venv: - -```shell script -# Get inside your cloned repo. -cd miru-v3cogs - -# Set up the venv. -virtualenv --python=/usr/bin/python3.8 venv - -# Atlernatively, install python3.8-venv (possibly python3.8-distutils) and do: -python3.8 -m venv venv - -# Activate your venv -source venv/bin/activate - -# Install python deps, including Red. -pip install -r requirements.txt +^addpath path/to/repo/root ``` -## Set up Red Bot +e.g ``` -# You will use the output from pwd in the next steps, so copy it -pwd - -# Initial setup of bot -redbot-setup - -# Use whatever you like below, this is my example -bot name> miru_bot_test +^addpath /Users/Tsubaki/src/pad-cogs +``` -# Replace current_dir with output from pwd -data location> (current_dir)/bot_data +> Note: you may have to do this for `pad-cogs`, `core-cogs`, and `misc-cogs`. If you have forked and cloned core & misc cogs, this process is the same! However, if you are only developing in `pad-cogs`, and installing `core-cogs` & `misc-cogs` from remote repos then: +> +> 1. `^load downloader` (this cog is always installed by default) +> 2. `^repo install misc https://github.com/TsubakiBotPad/misc-cogs` +> 3. `^repo install core https://github.com/TsubakiBotPad/core-cogs` +> 4. Now you can install, for example, the `menulistener` cog with `^repo install core menulistener`. The `menulistener` cog is what makes the `^id` menus interactive instead of single-panel static screens with unusable emoji below them. -# Pick json for now -storage backend> 1 +### Load cogs -# Now start the bot, replace the bot name below -redbot miru_bot_test +Load relevant cogs (whichever cogs you are developing, `dbcog`, and `padinfo`) using the `^load` command. -# Follow the directions to get your bot token -# If copying from a v2 bot, the token is in data/red/settings.json -bot token> MjQxdzM53TE3MAM2NzA3NTg2.Xc4VVA.RIcv2Ndkxb +> Node: We suggest you load `dbcog` and `padinfo` so that you will have something to test that your bot installation correctly installed; however, if your development does not involve dbcog as a dependency, you may want to keep it unloaded (`^unload dbcog`, get it back with `^load dbcog` at any time), simply because it is very slow for the bot to initialize dbcog on startup, and you will be restarting your bot a lot during development. -# Pick whatever you want but consider not overlapping with miru which uses ^ -prefix> ! ``` +Syntax: ^load ... -Once the bot is launched, set it to use your repo directory as a cog path. Type this in Discord where the bot is: - -``` -!addpath (pwd output) +e.g +^load dbcog padinfo ``` +You can see which cogs you have loaded using the `^cogs` command. +### Test a simple command +You should now be able to run basic commands like `^id` +``` +^id tsubaki +``` +Should return something like: +image +Happy developing! +## Appendix -## TODOs - -fork romkan and fix this so people don't keep having to do this - -* For Romanji/Kana conversion you will use Romkan. This is a bit tricky to install so we'll go over it first. - * If you want to skip this, you can locally comment out all calls to it, but this might be annoying when syncing your code if you're editing files that import it, so you should probably just do it. But if figuring this out is a barrier to entry to start actually coding, feel free to skip at least at the start. - * git clone [this repo](https://github.com/soimort/python-romkan) into any folder you want. - * Open setup.py in the text editor or IDE of your choice. Make the following replacement (basically add `encoding='utf8'` in 3 places): - ```python - here = os.path.abspath(os.path.dirname(__file__)) - proj_info = json.loads(open(os.path.join(here, PROJ_METADATA), encoding='utf8').read()) - README = open(os.path.join(here, 'README.rst'), encoding='utf8').read() - CHANGELOG = open(os.path.join(here, 'CHANGELOG.rst'), encoding='utf8').read() - VERSION = imp.load_source('version', os.path.join(here, 'src/%s/version.py' % PACKAGE_NAME)).__version__ - ``` - * Open a command line in this same directory and run `py setup.py install` - -## Setting up the bot - -* Some cogs will have cross dependencies on each other. Check the command prompt that Miru is running from if you encounter any errors. -* Common dependencies include: - * rpadutils - * dadguide - * padinfo -* Note that when you edit cogs with dependencies, you might need to do multiple reloads. For example, if updating dadguide, you will need to reload padinfo as well. -* After you have done all of this, restart the bot again. Hopefully by now `^id ` should work! +### Debugging -## Other +- Use `^traceback 1` if you encounter any errors to display them in discord. ### Emoji -* If you want emojis in `^id` commands, and you are setting this up for DEV PURPOSES ONLY, you can talk to tactical_retreat about getting your bot invited to the emoji servers. If you want to make your own separate Miru instance though, you're on your own for that. -* Give t_r your bot's invite link & ask him for the server IDs -* Then use `^padinfo setemojiservers` with the IDs he gives you. The main Miru server is one of them, you can get that ID yourself. - -# Puzzle and Dragons - -Most cogs here relate to the mobile game 'Puzzle and Dragons'. Data is sourced from the -DadGuide mobile app. - -| Cog | Purpose | -| --- | --- | -| damagecalc | Simple attack damage calculator | -| padboard | Converts board images to dawnglare board/solved board links | -| padglobal | Global PAD info commands | -| dadguide | Utility classes relating to DadGuide data | -| padinfo | Monster lookup and info display | -| padrem | Rare Egg Machine simulation | -| padvision | Utilities relating to PAD image scanning | -| profile | Global user PAD profile storage and lookup | - - -# Admin/util cogs - -Cogs that make server administration easier, do miscellaneous useful things, or -contain utility libraries. - -| Cog | Purpose | -| --- | --- | -| baduser | Tracks misbehaving users, other misc user tracking | -| calculator | Replacement for the calculator cog that doesnt suck | -| fancysay | Make the bot say special things | -| memes | CustomCommands except role-limited | -| rpadutils | Utility library shared by many other libraries | -| sqlactivitylog | Archives messages in sqlite, allows for lookup | -| timecog | Convert/print time in different timezones | -| trutils | Misc utilities intended for my usage only | -| twitter2 | Mirrors a twitter feed to a channel | - - -# Other/deprecated cogs - -Cogs not intended for normal use, or superceded. - -| Cog | Purpose | -| --- | --- | -| donations | Tracks users who have donated for hosting fees | +You may have noticed that emojis are replaced with placeholders in some commands. This is because the bot does not have access to the specific emojis. + +- If you are doing in-depth development of the `^id` command, your bot will need to be invited to the emoji servers. Talk to River to get access to these servers. + +- Once you and your bot have joined the servers, use `^padinfo emojiserver add ... ` with the IDs she gives you. + +### Cog list + +Most cogs here relate to the mobile game 'Puzzle & Dragons'. + +| Cog | Purpose | +| ------------- | --------------------------------------------------------------- | +| azurlane | Azur Lane card lookup | +| crowddata | Crowdsourced data collection | +| crud | Database editor. Not useful for bots other than prod Tsubaki | +| dbcog | Central cog to host all PAD data | +| dungeoncog | A cog to search and get info for dungeons | +| damagecalc | Simple attack damage calculator | +| feedback | A specialized feedback cog for Tsubaki specifically | +| monidlistener | A listener to give info for monster ids | +| padboard | Converts board images to dawnglare board/solved board links | +| padbuildimg | Creates images of PAD teams via a special query language | +| padbuilds | See user-made builds for dungeons i think??? (nobody uses this) | +| padevents | A scheduler cog to help players see upcoming daily GH dungeons. | +| padglobal | Global PAD info commands | +| padle | A wordle-inspired monster guessing game | +| padinfo | Monster lookup and info display | +| padmonitor | Keeps an eye out for PAD updates | +| pipelineui | Interface for running the pipeline (Tsubaki-only) | +| pricecheck | Lumon's thing. Something about stamina trade equivalence | +| profile | Global user PAD profile storage and lookup | diff --git a/__init__.py b/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 00000000..81f8b371 --- /dev/null +++ b/api/__init__.py @@ -0,0 +1,13 @@ +import json +from pathlib import Path + +from api.apicog import APICog + +with open(Path(__file__).parent / "info.json") as file: + __red_end_user_data_statement__ = json.load(file)['end_user_data_statement'] + + +async def setup(bot): + n = APICog(bot) + bot.add_cog(n) if not __import__('asyncio').iscoroutinefunction(bot.add_cog) else await bot.add_cog(n) + n.bot.loop.create_task(n.entrypoint()) diff --git a/api/api.py b/api/api.py new file mode 100644 index 00000000..12b5c91e --- /dev/null +++ b/api/api.py @@ -0,0 +1,36 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.routing import APIRoute + +from api.routers.monster import monster_router + +app = FastAPI( + title="Tsubotki API", + description="Tsubotki API", + version="0.0.1", +) + +origins = [ + "http://localhost", + "http://localhost:3000", + "https://www.tsubakibot.com" +] + +app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(monster_router, prefix="/monster", tags=["monster"], ) + + +def use_route_names_as_operation_ids(app: FastAPI) -> None: + for route in app.routes: + if isinstance(route, APIRoute): + route.operation_id = route.name # in this case, 'read_items' + + +use_route_names_as_operation_ids(app) diff --git a/api/apicog.py b/api/apicog.py new file mode 100644 index 00000000..0d34a3c9 --- /dev/null +++ b/api/apicog.py @@ -0,0 +1,26 @@ +import asyncio + +from redbot.core import commands +from uvicorn import Server, Config + +from .api import app +from .botref import set_bot + + +class APICog(commands.Cog): + server: Server + + def __init__(self, bot, *args, **kwargs): + super().__init__(*args, **kwargs) + self.bot = bot + set_bot(bot) + + async def entrypoint(self): + config = Config(app, host="0.0.0.0", port=8001, log_level="info") + self.server = Server(config=config) + await asyncio.create_task(self.server.serve()) + + async def cog_unload(self) -> None: + self.server.should_exit = True + self.server.force_exit = True + await self.server.shutdown() diff --git a/api/botref.py b/api/botref.py new file mode 100644 index 00000000..5274044a --- /dev/null +++ b/api/botref.py @@ -0,0 +1,10 @@ +bot = None + + +def set_bot(ref): + global bot + bot = ref + + +def get_bot(): + return bot diff --git a/api/info.json b/api/info.json new file mode 100644 index 00000000..c901e16f --- /dev/null +++ b/api/info.json @@ -0,0 +1,29 @@ +{ + "author": [ + "The Tsubaki Bot Team" + ], + "description": "Very few commands. exposes dbcog to web.", + "end_user_data_statement": "Nothing is stored in this cog.", + "install_msg": "Use [p]dbcog setdatafile if it's applicable to you.", + "short": "Exposes dbcog", + "min_bot_version": "3.4.0", + "max_bot_version": "0.0.0", + "min_python_version": [ + 3, + 8, + 1 + ], + "hidden": false, + "disabled": false, + "required_cogs": {}, + "requirements": [ + "tsutils>=5.0.0", + "pytz", + "regex", + "romkan", + "networkx" + ], + "tags": [ + "PAD" + ] +} diff --git a/api/responses/__init__.py b/api/responses/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/responses/active_skill.py b/api/responses/active_skill.py new file mode 100644 index 00000000..702e78d3 --- /dev/null +++ b/api/responses/active_skill.py @@ -0,0 +1,123 @@ +from typing import List + +from pydantic import BaseModel + +from dbcog.models.active_skill_model import ActiveSkillModel, ActiveSubskillModel, ActivePartModel + + +class ActivePart(BaseModel): + active_part_id: int + active_skill_type_id: int + desc_ja: str + desc_en: str + desc_ko: str + desc_templated_ja: str + desc_templated_en: str + desc_templated_ko: str + desc: str + desc_templated: str + + @staticmethod + def from_model(m: ActivePartModel): + return ActivePart( + active_part_id=m.active_part_id, + active_skill_type_id=m.active_skill_type_id, + desc_ja=m.desc_ja, + desc_en=m.desc_en, + desc_ko=m.desc_ko, + desc_templated_ja=m.desc_templated_ja, + desc_templated_en=m.desc_templated_en, + desc_templated_ko=m.desc_templated_ko, + desc=m.desc, + desc_templated=m.desc_templated, + ) + + +class ActiveSubskill(BaseModel): + active_subskill_id: int + name_ja: str + name_en: str + name_ko: str + desc_ja: str + desc_en: str + desc_ko: str + desc_templated_ja: str + desc_templated_en: str + desc_templated_ko: str + board_65: str + board_76: str + cooldown: int + active_parts: List[ActivePart] + name: str + desc: str + desc_templated: str + + @staticmethod + def from_model(m: ActiveSubskillModel): + return ActiveSubskill( + active_subskill_id=m.active_subskill_id, + name_ja=m.name_ja, + name_en=m.name_en, + name_ko=m.name_ko, + desc_ja=m.desc_ja, + desc_en=m.desc_en, + desc_ko=m.desc_ko, + desc_templated_ja=m.desc_templated_ja, + desc_templated_en=m.desc_templated_en, + desc_templated_ko=m.desc_templated_ko, + board_65=m.board_65, + board_76=m.board_76, + cooldown=m.cooldown, + active_parts=[ActivePart.from_model(a) for a in m.active_parts], + name=m.name, + desc=m.desc, + desc_templated=m.desc_templated, + ) + + +class ActiveSkill(BaseModel): + active_skill_id: int + compound_skill_type_id: int + name_ja: str + name_en: str + name_ko: str + desc_ja: str + desc_en: str + desc_ko: str + desc_templated_ja: str + desc_templated_en: str + desc_templated_ko: str + desc_official_ja: str + desc_official_en: str + desc_official_ko: str + cooldown_turns_max: int + cooldown_turns_min: int + active_subskills: List[ActiveSubskill] + name: str + desc: str + desc_templated: str + + @staticmethod + def from_model(m: ActiveSkillModel): + return ActiveSkill( + active_skill_id=m.active_skill_id, + compound_skill_type_id=m.compound_skill_type_id, + name_ja=m.name_ja, + name_en=m.name_en, + name_ko=m.name_ko, + desc_ja=m.desc_ja, + desc_en=m.desc_en, + desc_ko=m.desc_ko, + desc_templated_ja=m.desc_templated_ja, + desc_templated_en=m.desc_templated_en, + desc_templated_ko=m.desc_templated_ko, + desc_official_ja=m.desc_official_ja, + desc_official_en=m.desc_official_en, + desc_official_ko=m.desc_official_ko, + cooldown_turns_max=m.cooldown_turns_max, + cooldown_turns_min=m.cooldown_turns_min, + active_subskills=[ActiveSubskill.from_model(a) for a in m.active_subskills], + name=m.name, + desc=m.desc, + desc_templated=m.desc_templated, + ) diff --git a/api/responses/awakening.py b/api/responses/awakening.py new file mode 100644 index 00000000..86e6f030 --- /dev/null +++ b/api/responses/awakening.py @@ -0,0 +1,26 @@ +from pydantic import BaseModel + +from api.responses.awoken_skill import AwokenSkill +from dbcog.models.awakening_model import AwakeningModel + + +class Awakening(BaseModel): + awakening_id: int + monster_id: int + awoken_skill_id: int + is_super: int + order_idx: int + awoken_skill: AwokenSkill + name: str + + @staticmethod + def from_model(m: AwakeningModel): + return Awakening( + awakening_id=m.awakening_id, + monster_id=m.monster_id, + awoken_skill_id=m.awoken_skill_id, + is_super=m.is_super, + order_idx=m.order_idx, + awoken_skill=AwokenSkill.from_model(m.awoken_skill), + name=m.name, + ) diff --git a/api/responses/awoken_skill.py b/api/responses/awoken_skill.py new file mode 100644 index 00000000..d0e5ad90 --- /dev/null +++ b/api/responses/awoken_skill.py @@ -0,0 +1,33 @@ +from pydantic import BaseModel + +from dbcog.models.awoken_skill_model import AwokenSkillModel + + +class AwokenSkill(BaseModel): + awoken_skill_id: int + name_ja: str + name_en: str + name_ko: str + name: str + desc_ja: str + desc_en: str + desc_ko: str + adj_hp: int + adj_atk: int + adj_rcv: int + + @staticmethod + def from_model(m: AwokenSkillModel): + return AwokenSkill( + awoken_skill_id=m.awoken_skill_id, + name_ja=m.name_ja, + name_en=m.name_en, + name_ko=m.name_ko, + name=m.name, + desc_ja=m.desc_ja, + desc_en=m.desc_en, + desc_ko=m.desc_ko, + adj_hp=m.adj_hp, + adj_atk=m.adj_atk, + adj_rcv=m.adj_rcv, + ) diff --git a/api/responses/leader_skill.py b/api/responses/leader_skill.py new file mode 100644 index 00000000..503204e2 --- /dev/null +++ b/api/responses/leader_skill.py @@ -0,0 +1,45 @@ +from typing import List + +from pydantic import BaseModel + +from dbcog.models.leader_skill_model import LeaderSkillModel + + +class LeaderSkill(BaseModel): + leader_skill_id: int + name_ja: str + name_en: str + name_ko: str + max_hp: int + max_atk: int + max_rcv: int + max_shield: int + max_combos: int + bonus_damage: int + mult_bonus_damage: int + extra_time: int + tags: List[int] + desc_en: str + desc_ja: str + desc_ko: str + + @staticmethod + def from_model(m: LeaderSkillModel): + return LeaderSkill( + leader_skill_id=m.leader_skill_id, + name_ja=m.name_ja, + name_en=m.name_en, + name_ko=m.name_ko, + max_hp=m.max_hp, + max_atk=m.max_atk, + max_rcv=m.max_rcv, + max_shield=m.max_shield, + max_combos=m.max_combos, + bonus_damage=m.bonus_damage, + mult_bonus_damage=m.mult_bonus_damage, + extra_time=m.extra_time, + tags=m.tags, + desc_en=m.desc_en, + desc_ja=m.desc_ja, + desc_ko=m.desc_ko, + ) diff --git a/api/responses/monster.py b/api/responses/monster.py new file mode 100644 index 00000000..900df380 --- /dev/null +++ b/api/responses/monster.py @@ -0,0 +1,155 @@ +from typing import List, Optional + +from pydantic import BaseModel + +from api.responses.active_skill import ActiveSkill +from api.responses.awakening import Awakening +from api.responses.leader_skill import LeaderSkill +from api.responses.series import Series +from api.responses.stat_values import StatValues +from dbcog.models.enum_types import MonsterType, Attribute +from dbcog.models.monster_model import MonsterModel + + +class MonsterResponse(BaseModel): + active_skill: ActiveSkill + active_skill_id: int + all_series: List[Series] + atk_max: int + atk_min: int + atk_scale: int + attr1: Optional[Attribute] + attr2: Optional[Attribute] + awakenings: List[Awakening] + base_evo_id: int + bgm_id: Optional[int] + buy_mp: Optional[int] + collab_id: int + cost: int + evo_gem_id: Optional[int] + exp: int + fodder_exp: int + group_id: int + has_animation: bool + has_hqimage: bool + hp_max: int + hp_min: int + hp_scale: int + in_mpshop: bool + in_pem: bool + in_rem: bool + in_vem: bool + is_equip: bool + is_farmable: bool + is_inheritable: bool + is_stackable: bool + latent_slots: int + leader_skill: LeaderSkill + leader_skill_id: int + level: int + limit_mult: int + monster_id: int + monster_no_jp: int + monster_no_kr: int + monster_no_na: int + name_en: str + name_en_override: Optional[str] + name_ja: str + name_ko: str + on_jp: bool + on_kr: bool + on_na: bool + orb_skin_id: Optional[int] + rarity: int + rcv_max: int + rcv_min: int + rcv_scale: int + reg_date: str + roma_subname: Optional[str] + sell_gold: int + sell_mp: int + series: Series + series_id: int + server_priority: str + stat_values: StatValues + superawakening_count: int + type1: Optional[MonsterType] + type2: Optional[MonsterType] + type3: Optional[MonsterType] + types: List[MonsterType] + unoverridden_name_en: str + voice_id_jp: Optional[int] + voice_id_na: Optional[int] + + @staticmethod + def from_model(m: MonsterModel): + return MonsterResponse( + active_skill=ActiveSkill.from_model(m.active_skill), + active_skill_id=m.active_skill_id, + all_series=[Series.from_model(a) for a in m.all_series], + atk_max=m.atk_max, + atk_min=m.atk_min, + atk_scale=m.atk_scale, + attr1=m.attr1, + attr2=m.attr2, + awakenings=[Awakening.from_model(a) for a in m.awakenings], + base_evo_id=m.base_evo_id, + bgm_id=m.bgm_id, + buy_mp=m.buy_mp, + collab_id=m.collab_id, + cost=m.cost, + evo_gem_id=m.evo_gem_id, + exp=m.exp, + fodder_exp=m.fodder_exp, + group_id=m.group_id, + has_animation=m.has_animation, + has_hqimage=m.has_hqimage, + hp_max=m.hp_max, + hp_min=m.hp_min, + hp_scale=m.hp_scale, + in_mpshop=m.in_mpshop, + in_pem=m.in_pem, + in_rem=m.in_rem, + in_vem=m.in_vem, + is_equip=m.is_equip, + is_farmable=m.is_farmable, + is_inheritable=m.is_inheritable, + is_stackable=m.is_stackable, + latent_slots=m.latent_slots, + leader_skill=LeaderSkill.from_model(m.leader_skill), + leader_skill_id=m.leader_skill_id, + level=m.level, + limit_mult=m.limit_mult, + monster_id=m.monster_id, + monster_no_jp=m.monster_no_jp, + monster_no_kr=m.monster_no_kr, + monster_no_na=m.monster_no_na, + name_en=m.name_en, + name_en_override=m.name_en_override, + name_ja=m.name_ja, + name_ko=m.name_ko, + on_jp=m.on_jp, + on_kr=m.on_kr, + on_na=m.on_na, + orb_skin_id=m.orb_skin_id, + rarity=m.rarity, + rcv_max=m.rcv_max, + rcv_min=m.rcv_min, + rcv_scale=m.rcv_scale, + reg_date=str(m.reg_date), + roma_subname=m.roma_subname, + sell_gold=m.sell_gold, + sell_mp=m.sell_mp, + series=Series.from_model(m.series), + series_id=m.series_id, + server_priority=m.server_priority.name, + stat_values=StatValues(**m.stat_values), + superawakening_count=m.superawakening_count, + type1=m.type1, + type2=m.type2, + type3=m.type3, + types=m.types, + unoverridden_name_en=m.unoverridden_name_en, + voice_id_jp=m.voice_id_jp, + voice_id_na=m.voice_id_na, + ) diff --git a/api/responses/series.py b/api/responses/series.py new file mode 100644 index 00000000..41d04471 --- /dev/null +++ b/api/responses/series.py @@ -0,0 +1,21 @@ +from pydantic import BaseModel + +from dbcog.models.series_model import SeriesModel + + +class Series(BaseModel): + series_id: int + name_ja: str + name_en: str + name_ko: str + series_type: str + + @staticmethod + def from_model(m: SeriesModel): + return Series( + series_id=m.series_id, + name_ja=m.name_ja, + name_en=m.name_en, + name_ko=m.name_ko, + series_type=m.series_type, + ) diff --git a/api/responses/stat_values.py b/api/responses/stat_values.py new file mode 100644 index 00000000..71e351dd --- /dev/null +++ b/api/responses/stat_values.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel + + +class StatValue(BaseModel): + min: int + max: int + scale: int + + +class StatValues(BaseModel): + hp: StatValue + atk: StatValue + rcv: StatValue diff --git a/api/routers/__init__.py b/api/routers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/routers/monster.py b/api/routers/monster.py new file mode 100644 index 00000000..1c2b2686 --- /dev/null +++ b/api/routers/monster.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter + +from api.botref import get_bot +from api.responses.monster import MonsterResponse + +monster_router = APIRouter() + + +@monster_router.get("/{monster_id}", response_model=MonsterResponse) +async def get(monster_id): + dbcog = get_bot().get_cog("DBCog") + monster = await dbcog.find_monster(monster_id) + return MonsterResponse.from_model(monster) diff --git a/automod2/__init__.py b/automod2/__init__.py deleted file mode 100644 index a297064c..00000000 --- a/automod2/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .automod2 import * - - -def setup(bot): - bot.add_cog(AutoMod2(bot)) diff --git a/automod2/automod2.py b/automod2/automod2.py deleted file mode 100644 index 649248f1..00000000 --- a/automod2/automod2.py +++ /dev/null @@ -1,817 +0,0 @@ -""" -Lets you create patterns to match against messages and apply them as whitelists -or blacklists to a channel. - -If a violation occurs, the message will be deleted and the user notified. -""" -import asyncio -import re -from collections import defaultdict -from collections import deque -from datetime import datetime - -import discord -import prettytable -from redbot.core import checks -from redbot.core import commands -from redbot.core.utils.chat_formatting import inline, box, pagify - -import rpadutils -from rpadutils import CogSettings, boxPagifySay - -LOGS_PER_CHANNEL_USER = 5 - -AUTOMOD_HELP = """ -Automod works by creating named global patterns, and then applying them in -specific channels as either whitelist or blacklist rules. This allows you -to customize what text can be typed in a channel. Text from moderators is -always ignored by this cog. - -Check out [p]automod2 patterns to see the current server-specific list of patterns. - -Each pattern has an 'include' component and an 'exclude' component. If text -matches the include, then the rule matches. If it subsequently matches the -exclude, then it does not match. - -Here's an example pattern: -Rule Name Include regex Exclude regex ------------------------------------------------------------------------------ -messages must start with a room code ^\d{4}\s?\d{4}.* .*test.* - -This pattern will match values like: - 12345678 foo fiz - 1234 5678 bar baz - -However, if the pattern contains 'test', it won't match: - 12345678 foo fiz test bar baz - -To add the pattern, you'd use the following command: -[p]automod2 addpattern "messages must start with a room code" "^\d{4}\s?\d{4}.*" ".*test.*" - -Remember that to bundle multiple words together you need to surround the -argument with quotes, as above. - -Once you've added a pattern, you need to enable it in a channel using one -of [p]addwhitelist or [p]addblacklist, e.g.: - {0.prefix}automod2 addwhitelist "messages must start with a room code" - -If a channel has any whitelists, then text typed in the channel must match -AT LEAST one whitelist, or it will be deleted. If ANY blacklist is matched -the text will be deleted. - -You can see the configuration for the server using [p]automod2 list - -You can also prevent users from spamming images using [p]automod2 imagelimit -""" -EMOJIS = { - 'tips': [ - '\N{THUMBS UP SIGN}', - '\N{THUMBS DOWN SIGN}', - '\N{EYES}', - ] -} - - -def linked_img_count(message): - return len(message.embeds) + len(message.attachments) - - -class AutoMod2(commands.Cog): - def __init__(self, bot, *args, **kwargs): - super().__init__(*args, **kwargs) - self.bot = bot - - self.settings = AutoMod2Settings("automod2") - self.settings.cleanup() - self.channel_user_logs = defaultdict(lambda: deque(maxlen=LOGS_PER_CHANNEL_USER)) - - self.server_user_last = defaultdict(dict) - self.server_phrase_last = defaultdict(dict) - - @commands.command() - @checks.mod_or_permissions(manage_guild=True) - async def automodhelp(self, ctx): - """Sends you info on how to use automod.""" - for page in pagify(AUTOMOD_HELP.format(ctx.prefix), delims=['\n'], shorten_by=8): - await ctx.author.send(box(page)) - - @commands.group() - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def automod2(self, ctx): - """AutoMod2 tools. - - This cog works by creating named global patterns, and then applying them in - specific channels as either whitelist or blacklist rules. - - For more information, use [p]automodhelp - """ - - @automod2.command() - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def addpattern(self, ctx, name, include_pattern, exclude_pattern='', error=None): - """Add a pattern for use in this server.""" - if error is not None: - await ctx.send(inline('Too many inputs detected, check your quotes')) - return - try: - re.compile(include_pattern) - re.compile(exclude_pattern) - except Exception as ex: - await ctx.send(inline(str(ex))) - return - self.settings.addPattern(ctx.guild.id, name, include_pattern, exclude_pattern) - await ctx.send(inline('Added pattern')) - - @automod2.command() - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def rmpattern(self, ctx, *, name): - """Remove a pattern from this server. Pattern must not be in use.""" - exit_code = self.settings.rmPattern(ctx.guild.id, name) - if exit_code == 1: - await ctx.send(inline('Removed pattern')) - elif exit_code == 0: - await ctx.send(inline("Rule '{}' is undefined.".format(name))) - else: - await ctx.send(inline("Rule '{}' is in use.".format(name))) - - @automod2.command() - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def addwhitelist(self, ctx, *, name): - """Add the named pattern as a whitelist for this channel.""" - name = name.strip('"') - if self.settings.addWhitelist(ctx.guild.id, ctx.channel.id, name): - await ctx.send(inline('Added whitelist config for: ' + name)) - else: - await ctx.send(inline("Rule '{}' is undefined.".format(name))) - - @automod2.command() - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def rmwhitelist(self, ctx, *, name): - """Remove the named pattern as a whitelist for this channel.""" - if self.settings.rmWhitelist(ctx.guild.id, ctx.channel.id, name): - await ctx.send(inline('Removed whitelist config for: ' + name)) - else: - await ctx.send(inline("Rule '{}' is undefined.".format(name))) - - @automod2.command() - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def addblacklist(self, ctx, *, name): - """Add the named pattern as a blacklist for this channel.""" - name = name.strip('"') - if self.settings.addBlacklist(ctx.guild.id, ctx.channel.id, name): - await ctx.send(inline('Added blacklist config for: ' + name)) - else: - await ctx.send(inline("Rule '{}' is undefined.".format(name))) - - @automod2.command() - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def rmblacklist(self, ctx, *, name): - """Remove the named pattern as a blacklist for this channel.""" - if self.settings.rmBlacklist(ctx.guild.id, ctx.channel.id, name): - await ctx.send(inline('Removed blacklist config for: ' + name)) - else: - await ctx.send(inline("Rule '{}' is undefined.".format(name))) - - @automod2.command() - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def list(self, ctx): - """List the whitelist/blacklist configuration for the current channel.""" - output = 'AutoMod configs\n' - channels = self.settings.getChannels(ctx.guild.id) - for channel_id, config in channels.items(): - channel = self.bot.get_channel(int(channel_id)) - if channel is None: - continue - - whitelists = config['whitelist'] - blacklists = config['blacklist'] - image_limit = config.get('image_limit', 0) - auto_emojis_type = config.get('auto_emojis_type', None) - if len(whitelists + blacklists) + image_limit == 0 and not auto_emojis_type: - continue - - output += '\n#{}'.format(channel.name) - output += '\n\tWhitelists' - for name in whitelists: - output += '\t\t{}\n'.format(name) - output += '\n\tBlacklists' - for name in blacklists: - output += '\t\t{}\n'.format(name) - output += '\n\tImage Limit: {}'.format(image_limit) - output += '\n\tAuto Emojis Type: {}'.format(auto_emojis_type) - await boxPagifySay(ctx.send, output) - - @automod2.command() - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def patterns(self, ctx): - """List the registered patterns.""" - patterns = self.settings.getPatterns(ctx.guild.id) - output = 'AutoMod patterns for this server\n\n' - output += self.patternsToTableText(patterns.values()) - await boxPagifySay(ctx.send, output) - - @automod2.command() - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def imagelimit(self, ctx, limit: int): - """Prevents users from spamming images in a channel. - - If a user attempts to link/attach more than images in the active channel - within the the lookback window (currently 5), all those messages are deleted. - - Set to 0 to clear. - - Set to -1 to enable image only. - """ - self.settings.setImageLimit(ctx.guild.id, ctx.channel.id, limit) - if limit == 0: - await ctx.send(inline('Limit cleared')) - elif limit == -1: - await ctx.send(inline('I will only allow images in this channel')) - else: - await ctx.send(inline('I will delete excess images in this channel')) - - @commands.Cog.listener('on_message') - async def mod_message_images(self, message): - if message.author.id == self.bot.user.id or isinstance(message.channel, discord.abc.PrivateChannel): - return - image_limit = self.settings.getImageLimit(message.guild.id, message.channel.id) - if image_limit == 0: - return - elif image_limit > 0: - if message.channel.permissions_for(message.author).manage_messages: - return - - key = (message.channel.id, message.author.id) - self.channel_user_logs[key].append(message) - - user_logs = self.channel_user_logs[key] - count = 0 - for m in user_logs: - count += linked_img_count(m) - if count <= image_limit: - return - - for m in list(user_logs): - if linked_img_count(m) > 0: - try: - await m.delete() - except: - pass - try: - user_logs.remove(m) - except: - pass - - msg = m.author.mention + inline(' Upload multiple images to an imgur gallery #endimagespam') - alert_msg = await message.channel.send(msg) - await asyncio.sleep(10) - await alert_msg.delete() - else: - if message.channel.permissions_for(message.author).manage_messages: - return - if len(message.embeds) or len(message.attachments): - return - msg_template = box('Your message in {} was deleted for not containing an image') - msg = msg_template.format(message.channel.name) - await self.deleteAndReport(message, msg) - - @commands.Cog.listener('on_message_edit') - async def mod_message_edit(self, before, after): - await self.mod_message(after) - - @commands.Cog.listener('on_message') - async def mod_message(self, message): - if isinstance(message.channel, discord.abc.PrivateChannel): - return - - if message.author.bot or not isinstance(message.author, discord.Member): - return - - if message.channel.permissions_for(message.author).manage_messages: - return - - whitelists, blacklists = self.settings.getRulesForChannel(message.guild.id, message.channel.id) - - msg_template = box('Your message in {} was deleted for violating the following policy: {}\n' - 'Message content: {}') - - msg_content = message.clean_content - for value in blacklists: - name = value['name'] - include_pattern = value['include_pattern'] - exclude_pattern = value['exclude_pattern'] - - if not matchesIncludeExclude(include_pattern, exclude_pattern, msg_content): - continue - - msg = msg_template.format(message.channel.name, name, msg_content) - await self.deleteAndReport(message, msg) - - if len(whitelists): - failed_whitelists = list() - for value in whitelists: - name = value['name'] - include_pattern = value['include_pattern'] - exclude_pattern = value['exclude_pattern'] - - if matchesIncludeExclude(include_pattern, exclude_pattern, msg_content): - return - failed_whitelists.append(name) - - msg = msg_template.format(message.channel.name, - ','.join(failed_whitelists), msg_content) - await self.deleteAndReport(message, msg) - - @automod2.command() - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def autoemojis(self, ctx, key: str = None): - """Will automatically add a set of emojis to all messages sent in this channel. - - Currently supported: tips - - Include the text [noemojis] to suppress emoji addition - """ - if not key: - self.settings.setAutoEmojis(ctx.guild.id, ctx.channel.id, None) - await ctx.send(inline('Auto emojis cleared')) - return - key = key.lower() - if key not in EMOJIS: - await ctx.send(inline('Unknown key')) - return - self.settings.setAutoEmojis(ctx.guild.id, ctx.channel.id, key) - await ctx.send(inline('Auto Emojis for this channel configured!')) - - @commands.Cog.listener('on_message') - async def add_auto_emojis(self, message): - if message.author.id == self.bot.user.id or isinstance(message.channel, discord.abc.PrivateChannel): - return - key = self.settings.getAutoEmojis(message.guild.id, message.channel.id) - if not key: - return - if '[noemojis]' in message.content: - return - emoji_list = EMOJIS[key] - for emoji in emoji_list: - await message.add_reaction(emoji) - - @commands.group() - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def watchdog(self, ctx): - """User monitoring tools.""" - - @watchdog.command() - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def printconfig(self, ctx): - """Print the watchdog configuration.""" - server_id = ctx.guild.id - msg = 'Watchdog config:' - watchdog_channel_id = self.settings.getWatchdogChannel(server_id) - if watchdog_channel_id: - watchdog_channel = self.bot.get_channel(int(watchdog_channel_id)) - if watchdog_channel: - msg += '\nChannel: ' + watchdog_channel.name - else: - msg += '\nChannel configured but not found' - else: - msg += '\nChannel not set' - - msg += '\n\nUsers' - for user_id, user_settings in self.settings.getWatchdogUsers(server_id).items(): - user_cooldown = user_settings['cooldown'] - request_user_id = user_settings['request_user_id'] - reason = user_settings['reason'] or 'no reason' - - request_user = ctx.guild.get_member(int(request_user_id)) - request_user_txt = request_user.name if request_user else '???' - member = ctx.guild.get_member(int(user_id)) - if user_cooldown and member: - msg += '\n{} ({})\n\tcooldown {}\n\tby {} because [{}]'.format( - member.name, member.id, user_cooldown, request_user_txt, reason) - - msg += '\n\nPhrases' - for name, phrase_settings in self.settings.getWatchdogPhrases(server_id).items(): - phrase_cooldown = phrase_settings['cooldown'] - request_user_id = phrase_settings['request_user_id'] - phrase = phrase_settings['phrase'] - - request_user = ctx.guild.get_member(int(request_user_id)) - request_user_txt = request_user.name if request_user else '???' - if phrase_cooldown: - msg += '\n{} -> {}\n\tcooldown {}\n\tby {}'.format( - name, phrase, phrase_cooldown, request_user_txt) - - for page in pagify(msg): - await ctx.send(box(page)) - - @watchdog.command() - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def user(self, ctx, user: discord.User, cooldown: int = None, *, reason: str = ''): - """Keep an eye on a user. - - Whenever the user speaks in this server, a note will be printed to the watchdog - channel, subject to the specified cooldown in seconds. Set to 0 to clear. - """ - server_id = ctx.guild.id - if cooldown is None: - user_settings = self.settings.getWatchdogUsers(server_id).get(user.id, {}) - existing_cd = user_settings.get('cooldown', 0) - if existing_cd == 0: - await ctx.send(inline('No watchdog for that user')) - else: - await ctx.send(inline('Watchdog set with cooldown of {} seconds'.format(existing_cd))) - else: - self.settings.setWatchdogUser( - server_id, user.id, ctx.author.id, cooldown, reason) - if cooldown == 0: - await ctx.send(inline('Watchdog cleared for {}'.format(user.name))) - else: - await ctx.send(inline('Watchdog set on {} with cooldown of {} seconds'.format(user.name, cooldown))) - - @watchdog.command() - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def phrase(self, ctx, name: str, cooldown: int, *, phrase: str = None): - """Keep an eye out for a phrase (regex). - - Whenever the regex is matched, a note will be printed to the watchdog - channel, subject to the specified cooldown in seconds. - - Name is descriptive. Set a cooldown of 0 to clear. - """ - server_id = ctx.guild.id - - if cooldown == 0: - self.settings.setWatchdogPhrase( - server_id, name, None, None, None) - await ctx.send(inline('Watchdog phrase cleared for {}'.format(name))) - return - - try: - re.compile(phrase) - except Exception as ex: - raise commands.UserFeedbackCheckFailure(str(ex)) - - if cooldown < 300: - await ctx.send(inline('Overriding cooldown to minimum')) - cooldown = 300 - self.settings.setWatchdogPhrase(server_id, name, ctx.author.id, cooldown, phrase) - self.server_phrase_last[server_id][name] = None - await ctx.send(inline('Watchdog named {} set on {} with cooldown of {} seconds'.format(name, phrase, cooldown))) - - @watchdog.command() - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def channel(self, ctx, channel: discord.TextChannel): - """Set the announcement channel.""" - server_id = ctx.guild.id - self.settings.setWatchdogChannel(server_id, channel.id) - await ctx.send(inline('Watchdog channel set')) - - @commands.Cog.listener('on_message') - async def mod_message_watchdog(self, message): - if message.author.id == self.bot.user.id or isinstance(message.channel, discord.abc.PrivateChannel): - return - if self.settings.getWatchdogChannel(message.guild.id) is None: - return - - await self.mod_message_watchdog_user(message) - await self.mod_message_watchdog_phrase(message) - - async def mod_message_watchdog_user(self, message): - user_id = message.author.id - server_id = message.guild.id - watchdog_channel_id = self.settings.getWatchdogChannel(server_id) - user_settings = self.settings.getWatchdogUsers(server_id).get(user_id) - - if user_settings is None: - return - - cooldown = user_settings['cooldown'] - if cooldown <= 0: - return - - request_user_id = user_settings['request_user_id'] - reason = user_settings['reason'] or 'no reason' - - request_user = message.guild.get_member(request_user_id) - request_user_txt = request_user.mention if request_user else '???' - - now = datetime.utcnow() - last_spoke_at = self.server_user_last[server_id].get(user_id) - self.server_user_last[server_id][user_id] = now - time_since = (now - last_spoke_at).total_seconds() if last_spoke_at else 9999 - - report = time_since > cooldown - if not report: - return - - output_msg = '**Watchdog:** {} spoke in {} ({} monitored because [{}])\n{}'.format( - message.author.mention, message.channel.mention, - request_user_txt, reason, box(message.clean_content)) - await self._watchdog_print(watchdog_channel_id, output_msg) - - async def mod_message_watchdog_phrase(self, message): - server_id = message.guild.id - watchdog_channel_id = self.settings.getWatchdogChannel(server_id) - - for phrase_settings in self.settings.getWatchdogPhrases(server_id).values(): - name = phrase_settings['name'] - cooldown = phrase_settings['cooldown'] - request_user_id = phrase_settings['request_user_id'] - phrase = phrase_settings['phrase'] - - if cooldown <= 0: - continue - - now = datetime.utcnow() - last_spoke_at = self.server_phrase_last[server_id].get(name) - time_since = (now - last_spoke_at).total_seconds() if last_spoke_at else 9999 - - report = time_since > cooldown - if not report: - continue - - p = re.compile(phrase, re.IGNORECASE | re.MULTILINE | re.DOTALL) - if p.match(message.clean_content): - self.server_phrase_last[server_id][name] = now - output_msg = '**Watchdog:** {} spoke in {} `(rule [{}] matched phrase [{}])`\n{}'.format( - message.author.mention, message.channel.mention, - name, phrase, box(message.clean_content)) - await self._watchdog_print(watchdog_channel_id, output_msg) - return - - async def _watchdog_print(self, watchdog_channel_id, output_msg): - try: - watchdog_channel = self.bot.get_channel(watchdog_channel_id) - await watchdog_channel.send(output_msg) - except Exception as ex: - print('failed to watchdog', str(ex)) - - async def deleteAndReport(self, delete_msg, outgoing_msg): - try: - await delete_msg.delete() - await delete_msg.author.send(outgoing_msg) - except Exception as e: - print('Failure while deleting message from {}, tried to send : {}'.format( - delete_msg.author.name, outgoing_msg)) - print(str(e)) - - def patternsToTableText(self, patterns): - tbl = prettytable.PrettyTable(["Rule Name", "Include regex", "Exclude regex"]) - tbl.hrules = prettytable.HEADER - tbl.vrules = prettytable.NONE - tbl.align = "l" - - for value in patterns: - tbl.add_row([value['name'], value['include_pattern'], value['exclude_pattern']]) - - return rpadutils.strip_right_multiline(tbl.get_string()) - - -def matchesPattern(pattern, txt): - if not len(pattern): - return False - - try: - if pattern[0] == pattern[-1] == ':': - check_method = globals().get(pattern[1:-1]) - if check_method: - return check_method(txt) - except: - return False - - p = re.compile(pattern, re.IGNORECASE | re.MULTILINE | re.DOTALL) - return p.match(txt) - - -def starts_with_code(txt): - # ignore spaces before or in code - txt = txt.replace(' ', '') - # ignore tilde, some users use them to cross out rooms - txt = txt.replace('~', '') - if len(txt) < 8: - return False - return pad_checkdigit(txt[0:8]) - - -def pad_checkdigit(n): - n = str(n) - checkdigit = int(n[7]) - sum = 7 - for idx in range(0, 7): - sum += int(n[idx]) - calcdigit = sum % 10 - return checkdigit == calcdigit - - -def matchesIncludeExclude(include_pattern, exclude_pattern, txt): - if matchesPattern(include_pattern, txt): - return not matchesPattern(exclude_pattern, txt) - return False - - -class AutoMod2Settings(CogSettings): - def make_default_settings(self): - config = { - 'configs': {} - } - return config - - def cleanup(self): - for server in self.serverConfigs().values(): - channels = server['channels'] - for channel_id in list(channels.keys()): - channel = channels[channel_id] - if any([channel.get(x) for x in ['whitelist', 'blacklist', 'image_limit', 'auto_emojis_type']]): - continue - channels.pop(channel_id) - - def serverConfigs(self): - return self.bot_settings['configs'] - - def getServer(self, server_id): - configs = self.serverConfigs() - if server_id not in configs: - configs[server_id] = { - 'patterns': {}, - 'channels': {}, - } - return configs[server_id] - - def getChannels(self, server_id): - server = self.getServer(server_id) - if 'channels' not in server: - server['channels'] = {} - return server['channels'] - - def getChannel(self, server_id, channel_id): - channels = self.getChannels(server_id) - - if channel_id not in channels: - channels[channel_id] = { - 'whitelist': [], - 'blacklist': [], - 'image_limit': 0, - 'auto_emojis_type': None - } - - return channels[channel_id] - - def getRulesForChannel(self, server_id, channel_id): - patterns = self.getPatterns(server_id) - channel = self.getChannel(server_id, channel_id) - - whitelist = [patterns[name] for name in channel['whitelist']] - blacklist = [patterns[name] for name in channel['blacklist']] - return whitelist, blacklist - - def getPatterns(self, server_id): - server = self.getServer(server_id) - if 'patterns' not in server: - server['patterns'] = {} - return server['patterns'] - - def addPattern(self, server_id, name, include_pattern, exclude_pattern): - patterns = self.getPatterns(server_id) - patterns[name] = { - 'name': name, - 'include_pattern': include_pattern, - 'exclude_pattern': exclude_pattern, - } - self.save_settings() - - def checkPatternUsed(self, server_id, name): - server = self.getServer(server_id) - for channel_id, channel_config in server['channels'].items(): - if name in channel_config['whitelist']: - return True - if name in channel_config['blacklist']: - return True - return False - - def rmPattern(self, server_id, name): - patterns = self.getPatterns(server_id) - if self.checkPatternUsed(server_id, name): - return -1 - elif name not in patterns: - return 0 - patterns.pop(name) - self.save_settings() - return 1 - - def addRule(self, server_id, channel_id, name, list_type): - patterns = self.getPatterns(server_id) - if name not in patterns: - return False - self.getChannel(server_id, channel_id)[list_type].append(name) - self.save_settings() - return True - - def rmRule(self, server_id, channel_id, name, list_type): - chan_list = self.getChannel(server_id, channel_id)[list_type] - if name in chan_list: - chan_list.remove(name) - self.save_settings() - return True - else: - return False - - def addWhitelist(self, server_id, channel_id, name): - return self.addRule(server_id, channel_id, name, 'whitelist') - - def rmWhitelist(self, server_id, channel_id, name): - return self.rmRule(server_id, channel_id, name, 'whitelist') - - def addBlacklist(self, server_id, channel_id, name): - return self.addRule(server_id, channel_id, name, 'blacklist') - - def rmBlacklist(self, server_id, channel_id, name): - return self.rmRule(server_id, channel_id, name, 'blacklist') - - def getImageLimit(self, server_id, channel_id): - channel = self.getChannel(server_id, channel_id) - return channel.get('image_limit', 0) - - def setImageLimit(self, server_id, channel_id, image_limit): - channel = self.getChannel(server_id, channel_id) - channel['image_limit'] = image_limit - self.save_settings() - - def getAutoEmojis(self, server_id, channel_id): - channel = self.getChannel(server_id, channel_id) - return channel.get('auto_emojis_type', None) - - def setAutoEmojis(self, server_id, channel_id, key): - channel = self.getChannel(server_id, channel_id) - channel['auto_emojis_type'] = key - self.save_settings() - - def getWatchdog(self, server_id): - server = self.getServer(server_id) - key = 'watchdog' - if key not in server: - server[key] = {} - return server[key] - - def getWatchdogChannel(self, server_id): - watchdog = self.getWatchdog(server_id) - return watchdog.get('announce_channel') - - def setWatchdogChannel(self, server_id, channel_id): - watchdog = self.getWatchdog(server_id) - watchdog['announce_channel'] = channel_id - self.save_settings() - - def getWatchdogUsers(self, server_id): - watchdog = self.getWatchdog(server_id) - key = 'users' - if key not in watchdog: - watchdog[key] = {} - return watchdog[key] - - def setWatchdogUser(self, server_id, user_id, request_user_id, cooldown_secs, reason): - watchdog_users = self.getWatchdogUsers(server_id) - if cooldown_secs: - watchdog_users[user_id] = { - 'request_user_id': request_user_id, - 'cooldown': cooldown_secs, - 'reason': reason, - } - else: - watchdog_users.pop(user_id, None) - self.save_settings() - - def getWatchdogPhrases(self, server_id): - watchdog = self.getWatchdog(server_id) - key = 'phrases' - if key not in watchdog: - watchdog[key] = {} - return watchdog[key] - - def setWatchdogPhrase(self, server_id, name, request_user_id, cooldown_secs, phrase): - watchdog_phrases = self.getWatchdogPhrases(server_id) - if cooldown_secs: - watchdog_phrases[name] = { - 'name': name, - 'request_user_id': request_user_id, - 'cooldown': cooldown_secs, - 'phrase': phrase, - } - else: - watchdog_phrases.pop(name, None) - self.save_settings() diff --git a/azurelane/__init__.py b/azurelane/__init__.py deleted file mode 100644 index ceba31fd..00000000 --- a/azurelane/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from .azurelane import * - - -def setup(bot): - n = AzureLane(bot) - bot.add_cog(n) - bot.loop.create_task(n.reload_al()) diff --git a/azurelane/azurelane.py b/azurelane/azurelane.py deleted file mode 100644 index 2d98465a..00000000 --- a/azurelane/azurelane.py +++ /dev/null @@ -1,107 +0,0 @@ -import difflib -import json -from _collections import OrderedDict - -import aiohttp -import discord -from redbot.core import commands -from redbot.core.bot import Red -from redbot.core.utils.chat_formatting import inline - -from rpadutils import Menu, EmojiUpdater, char_to_emoji - -BASE_URL = 'https://storage.googleapis.com/mirubot/alimages/raw' -DATA_URL = '{}/azure_lane.json'.format(BASE_URL) - - -class AzureLane(commands.Cog): - """AzureLane.""" - - def __init__(self, bot: Red, *args, **kwargs): - super().__init__(*args, **kwargs) - self.bot = bot - self.card_data = [] - self.menu = Menu(bot) - - async def reload_al(self): - await self.bot.wait_until_ready() - - async with aiohttp.ClientSession() as session: - async with session.get(DATA_URL) as resp: - raw_resp = await resp.text() - self.card_data = json.loads(raw_resp)['items'] - print('done retrieving cards: {}'.format(len(self.card_data))) - - self.id_to_card = {c['id']: c for c in self.card_data} - name_to_card = {'{}'.format(c['name_en']).lower(): c for c in self.card_data} - # collection_name_to_card = {'{} {}'.format( - # i['title'], c['name_en']).lower(): c for i in c['images'] for c in self.card_data} - self.names_to_card = { - **name_to_card, - # **collection_name_to_card, - } - - @commands.command() - async def alid(self, ctx, *, query: str): - """Azure Lane query.""" - query = query.lower().strip() - c = None - if query.isdigit(): - c = self.id_to_card.get(int(query), None) - else: - c = self.names_to_card.get(query, None) - if c is None: - matches = difflib.get_close_matches( - query, self.names_to_card.keys(), n=1, cutoff=.6) - if len(matches): - c = self.names_to_card[matches[0]] - - if c: - await self.do_menu(ctx, c) - else: - await ctx.send(inline('no matches')) - - async def do_menu(self, ctx, c): - emoji_to_embed = OrderedDict() - for idx, image_info in enumerate(c['images']): - emoji = char_to_emoji(str(idx)) - emoji_to_embed[emoji] = make_card_embed(c, idx) - starting_menu_emoji = list(emoji_to_embed.keys())[0] - return await self._do_menu(ctx, starting_menu_emoji, emoji_to_embed) - - async def _do_menu(self, ctx, starting_menu_emoji, emoji_to_embed): - remove_emoji = self.menu.emoji['no'] - emoji_to_embed[remove_emoji] = self.menu.reaction_delete_message - - try: - result_msg, result_embed = await self.menu.custom_menu(ctx, - EmojiUpdater(emoji_to_embed), starting_menu_emoji, - timeout=20) - if result_msg and result_embed: - # Message is finished but not deleted, clear the footer - result_embed.set_footer(text=discord.Embed.Empty) - await result_msg.edit(embed=result_embed) - except Exception as ex: - print('Menu failure', ex) - - -def make_card_embed(c, image_idx): - cid = c['id'] - name = c['name_en'] - print(c) - info_url = c['url'] - image = c['images'][image_idx] - image_title = image['title'] - - url = image['url'] - file_name = url[url.rfind("/") + 1:] - - header = 'No. {} {} - {}'.format(cid, name, image_title) - image_url = 'https://storage.googleapis.com/mirubot/alimages/raw/{}'.format(file_name) - - embed = discord.Embed() - embed.title = header - embed.url = info_url - embed.set_image(url=image_url) - embed.set_footer(text='Requester may click the reactions below to switch tabs') - return embed diff --git a/azurlane/__init__.py b/azurlane/__init__.py new file mode 100644 index 00000000..a71dfdd9 --- /dev/null +++ b/azurlane/__init__.py @@ -0,0 +1,10 @@ +from .azurlane import AzurLane + +__red_end_user_data_statement__ = "No personal data is stored." + + +async def setup(bot): + n = AzurLane(bot) + bot.add_cog(n) if not __import__('asyncio').iscoroutinefunction(bot.add_cog) else await bot.add_cog(n) + bot.loop.create_task(n.register_menu()) + bot.loop.create_task(n.reload_al()) diff --git a/azurlane/azurlane.py b/azurlane/azurlane.py new file mode 100644 index 00000000..095c710d --- /dev/null +++ b/azurlane/azurlane.py @@ -0,0 +1,101 @@ +import difflib +import json +import logging +from io import BytesIO +from typing import Any + +import aiohttp +from redbot.core import checks, commands, Config +from tsutils.menu.components.config import BotConfig +from tsutils.query_settings.query_settings import QuerySettings +from tsutils.user_interaction import send_cancellation_message + +from azurlane.azurlane_view import AzurlaneViewState +from azurlane.menu import AzurlaneMenu, AzurlaneMenuPanes +from azurlane.menu_map import azurlane_menu_map +from azurlane.resources import DATA_URL + +logger = logging.getLogger('red.padbot-cogs.azurlane') + + +class AzurLane(commands.Cog): + """AzurLane.""" + + menu_map = azurlane_menu_map + + def __init__(self, bot, *args, **kwargs): + super().__init__(*args, **kwargs) + self.bot = bot + self.card_data = [] + + self.config = Config.get_conf(self, identifier=9401770) + + self.id_to_card = None + self.names_to_card = None + + async def red_get_data_for_user(self, *, user_id): + """Get a user's personal data.""" + data = "No data is stored for user with ID {}.\n".format(user_id) + return {"user_data.txt": BytesIO(data.encode())} + + async def red_delete_data_for_user(self, *, requester, user_id): + """Delete a user's personal data. + + No personal data is stored in this cog. + """ + return + + async def register_menu(self): + await self.bot.wait_until_ready() + menulistener = self.bot.get_cog("MenuListener") + if menulistener is None: + logger.warning("MenuListener is not loaded.") + return + await menulistener.register(self) + + async def get_menu_default_data(self, ims): + data = { + 'alcog': self, + 'user_config': await BotConfig.get_user(self.config, ims['original_author_id']), + } + return data + + async def reload_al(self): + await self.bot.wait_until_ready() + + async with aiohttp.ClientSession() as session: + async with session.get(DATA_URL) as resp: + raw_resp = await resp.text() + self.card_data = json.loads(raw_resp)['items'] + logger.info(f'Done retrieving cards: {len(self.card_data)}') + + self.id_to_card = {c['id']: c for c in self.card_data} + self.names_to_card = {'{}'.format(c['name_en']).lower(): c for c in self.card_data} + + @commands.command() + @checks.bot_has_permissions(embed_links=True) + async def alid(self, ctx, *, query: str): + """Azure Lane query.""" + query = query.lower().strip() + c = None + if query.isdigit(): + c = self.id_to_card.get(int(query), None) + else: + c = self.names_to_card.get(query, None) + if c is None: + matches = difflib.get_close_matches( + query, self.names_to_card.keys(), n=1, cutoff=.6) + if len(matches): + c = self.names_to_card[matches[0]] + + if not c: + return await send_cancellation_message(ctx, f'No matches for query `{query}`.') + reaction_list = AzurlaneMenuPanes.get_initial_reaction_list(len(c['images'])) + menu = AzurlaneMenu.menu() + query_settings = await QuerySettings.extract_raw(ctx.author, self.bot, query) + state = AzurlaneViewState(ctx.message.author.id, AzurlaneMenu.MENU_TYPE, + query_settings, + c, 0, + reaction_list=reaction_list) + + await menu.create(ctx, state) diff --git a/azurlane/azurlane_view.py b/azurlane/azurlane_view.py new file mode 100644 index 00000000..b7de8488 --- /dev/null +++ b/azurlane/azurlane_view.py @@ -0,0 +1,80 @@ +from typing import Collection, TYPE_CHECKING + +from discordmenu.embed.components import EmbedBodyImage, EmbedMain +from discordmenu.embed.view import EmbedView +from tsutils.menu.components.config import UserConfig +from tsutils.menu.components.footers import embed_footer_with_state +from tsutils.menu.view.view_state_base import ViewStateBase +from tsutils.query_settings.query_settings import QuerySettings + +if TYPE_CHECKING: + from dbcog.models.monster_model import MonsterModel + +ORDINAL_WORDS = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth'] + + +class AzurlaneViewState(ViewStateBase): + def __init__(self, original_author_id, menu_type, query_settings: QuerySettings, + c, image_idx, + extra_state=None, reaction_list=None + ): + super().__init__(original_author_id, menu_type, '', extra_state=extra_state, reaction_list=reaction_list) + self.query_settings = query_settings + self.menu_type = menu_type + self.image_idx = image_idx + self.c = c + + def serialize(self): + ret = super().serialize() + ret.update({ + 'idx': self.c['id'], + 'query_settings': self.query_settings.serialize(), + 'current_index': self.image_idx, + }) + return ret + + @classmethod + async def deserialize(cls, alcog, _user_config: UserConfig, ims: dict): + original_author_id = ims['original_author_id'] + menu_type = ims['menu_type'] + query_settings = QuerySettings.deserialize(ims.get('query_settings')) + reaction_list = ims['reaction_list'] + image_idx = ims['current_index'] + + card = alcog.id_to_card[ims['idx']] + return AzurlaneViewState(original_author_id, menu_type, query_settings, card, image_idx, + reaction_list=reaction_list) + + +class AzurlaneView: + VIEW_TYPE = 'Azurlane' + + @staticmethod + def get_count(arr: Collection["MonsterModel"], *elements: "MonsterModel") -> str: + if not arr: + return "N/A" + count = sum(1 for m in arr if m in elements) + if count == 0: + return "0" + return f"{round(100 * count / len(arr), 3)}%" + + @classmethod + def embed(cls, state: AzurlaneViewState): + c = state.c + + cid = c['id'] + name = c['name_en'] + image = c['images'][state.image_idx] + image_title = image['title'] + + url = image['url'] + + return EmbedView( + EmbedMain( + color=state.query_settings.embedcolor, + title=f'[{cid}] {name} - {image_title}', + url=url + ), + embed_body_image=EmbedBodyImage(url), + embed_footer=embed_footer_with_state(state) + ) diff --git a/azurlane/info.json b/azurlane/info.json new file mode 100644 index 00000000..9690c9f2 --- /dev/null +++ b/azurlane/info.json @@ -0,0 +1,27 @@ +{ + "author": [ + "The Tsubaki Bot Team" + ], + "description": "Displays data about Azur Lane.", + "end_user_data_statement": "No personal data is stored.", + "install_msg": "Use [p]alid to get started.", + "short": "Shows Azur Lane data", + "min_bot_version": "3.4.0", + "max_bot_version": "0.0.0", + "min_python_version": [ + 3, + 8, + 1 + ], + "hidden": false, + "disabled": false, + "required_cogs": {}, + "requirements": [ + "aiohttp", + "tsutils>=5.0.0" + ], + "tags": [ + "azurlane", + "azurelane" + ] +} diff --git a/azurlane/menu.py b/azurlane/menu.py new file mode 100644 index 00000000..5f1bd9ed --- /dev/null +++ b/azurlane/menu.py @@ -0,0 +1,57 @@ +import string +from functools import partial +from typing import Optional + +from discord import Message +from discordmenu.embed.menu import EmbedMenu +from discordmenu.embed.wrapper import EmbedWrapper +from tsutils.emoji import char_to_emoji +from tsutils.menu.components.panes import MenuPanes + +from azurlane.azurlane_view import AzurlaneView, AzurlaneViewState + +alid_emoji_order = [char_to_emoji(x) for x in [*range(11), *string.ascii_uppercase]] + + +class AzurlaneMenu: + MENU_TYPE = AzurlaneView.VIEW_TYPE + + @staticmethod + def menu(initial_control=None): + if initial_control is None: + initial_control = AzurlaneMenu.pane_control + embed = EmbedMenu(AzurlaneMenuPanes.transitions(), initial_control) + return embed + + @staticmethod + async def respond_with_n(n, message: Optional[Message], ims, **data): + ims['current_index'] = n + return await AzurlaneMenu.respond_with_pane(message, ims, **data) + + @classmethod + async def respond_with_pane(cls, message: Optional[Message], ims, **data): + alcog = data['alcog'] + user_config = data['user_config'] + view_state = await AzurlaneViewState.deserialize(alcog, user_config, ims) + return AzurlaneMenu.pane_control(view_state) + + @classmethod + def pane_control(cls, state: AzurlaneViewState): + if state is None: + return None + reaction_list = state.reaction_list + return EmbedWrapper( + AzurlaneView.embed(state), + reaction_list + ) + + +class AzurlaneMenuPanes(MenuPanes): + NON_MONSTER_EMOJI_COUNT = 0 + + DATA = {emoji: (partial(AzurlaneMenu.respond_with_n, c), AzurlaneView.VIEW_TYPE) + for c, emoji in enumerate(alid_emoji_order)} + + @classmethod + def get_initial_reaction_list(cls, number_of_skins: int): + return cls.emoji_names()[:number_of_skins + cls.NON_MONSTER_EMOJI_COUNT] diff --git a/azurlane/menu_map.py b/azurlane/menu_map.py new file mode 100644 index 00000000..47989b04 --- /dev/null +++ b/azurlane/menu_map.py @@ -0,0 +1,5 @@ +from azurlane.menu import AzurlaneMenu, AzurlaneMenuPanes + +azurlane_menu_map = { + AzurlaneMenu.MENU_TYPE: (AzurlaneMenu, AzurlaneMenuPanes), +} diff --git a/azurlane/resources.py b/azurlane/resources.py new file mode 100644 index 00000000..bbf5529f --- /dev/null +++ b/azurlane/resources.py @@ -0,0 +1,4 @@ +from tsutils.tsubaki.links import CLOUDFRONT_URL + +BASE_URL = f'{CLOUDFRONT_URL}/azurlane/{{}}' +DATA_URL = BASE_URL.format('data.json') diff --git a/baduser/__init__.py b/baduser/__init__.py deleted file mode 100644 index 75e19fac..00000000 --- a/baduser/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .baduser import * - - -def setup(bot): - bot.add_cog(BadUser(bot)) diff --git a/baduser/baduser.py b/baduser/baduser.py deleted file mode 100644 index ff338c51..00000000 --- a/baduser/baduser.py +++ /dev/null @@ -1,608 +0,0 @@ -""" -Utilities for managing misbehaving users and facilitating administrator -communication about role changes. -""" - -from collections import defaultdict -from collections import deque - -import discord -from redbot.core import checks -from redbot.core import commands -from redbot.core.bot import Red -from redbot.core.utils.chat_formatting import box, inline, pagify - -from rpadutils import CogSettings, get_role, get_role_from_id - -LOGS_PER_USER = 10 - - -class BadUser(commands.Cog): - def __init__(self, bot: Red, *args, **kwargs): - super().__init__(*args, **kwargs) - self.bot = bot - - self.settings = BadUserSettings("baduser") - self.logs = defaultdict(lambda: deque(maxlen=LOGS_PER_USER)) - - @commands.group() - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def baduser(self, ctx): - """BadUser tools. - - The scope of this module has expanded a bit. It now covers both 'positive' and 'negative' - roles. The goal is to assist coordination across moderators. - - When a user receives a negative role, a strike is automatically recorded for them. This - captures their recent message history. - - You can specify a moderation channel for announcements. An announcement occurs on the - following events: - * User gains or loses a negative/positive role (includes a ping to @here) - * User with a strike leaves the server - * User with a strike joins the server (includes a ping to @here) - - Besides the automatic tracking, you can manually add strikes, print them, and clear them. - """ - - @baduser.command(name="addnegativerole") - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def addNegativeRole(self, ctx, *, role): - """Designate a role as a 'punishment' role.""" - role = get_role(ctx.guild.roles, role) - self.settings.addPunishmentRole(ctx.guild.id, role.id) - await ctx.send(inline('Added punishment role "' + role.name + '"')) - - @baduser.command(name="rmnegativerole") - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def rmNegativeRole(self, ctx, *, role): - """Cancels a role from 'punishment' status.""" - role = get_role(ctx.guild.roles, role) - self.settings.rmPunishmentRole(ctx.guild.id, role.id) - await ctx.send(inline('Removed punishment role "' + role.name + '"')) - - @baduser.command(name="addpositiverole") - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def addPositiveRole(self, ctx, *, role): - """Designate a role as a 'benefit' role.""" - role = get_role(ctx.guild.roles, role) - self.settings.addPositiveRole(ctx.guild.id, role.id) - await ctx.send(inline('Added positive role "' + role.name + '"')) - - @baduser.command(name="rmpositiverole") - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def rmPositiveRole(self, ctx, *, role): - """Cancels a role from 'benefit' status.""" - role = get_role(ctx.guild.roles, role) - self.settings.rmPositiveRole(ctx.guild.id, role.id) - await ctx.send(inline('Removed positive role "' + role.name + '"')) - - @baduser.command(name="addneutralrole") - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def addNeutralRole(self, ctx, *, role): - """Designate a role as a notable but not ping-worthy role.""" - role = get_role(ctx.guild.roles, role) - self.settings.addNeutralRole(ctx.guild.id, role.id) - await ctx.send(inline('Added neutral role "' + role.name + '"')) - - @baduser.command(name="rmneutralrole") - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def rmNeutralRole(self, ctx, *, role): - """Cancels a role from notable but not ping-worthy status.""" - role = get_role(ctx.guild.roles, role) - self.settings.rmNeutralRole(ctx.guild.id, role.id) - await ctx.send(inline('Removed neutral role "' + role.name + '"')) - - @baduser.command(name="setchannel") - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def setChannel(self, ctx, channel: discord.TextChannel): - """Set the channel for moderation announcements.""" - self.settings.updateChannel(ctx.guild.id, channel.id) - await ctx.send(inline('Set the announcement channel')) - - @baduser.command(name="clearchannel") - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def clearChannel(self, ctx): - """Clear the channel for moderation announcements.""" - self.settings.updateChannel(ctx.guild.id, None) - await ctx.send(inline('Cleared the announcement channel')) - - @baduser.command() - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def togglestrikeprivacy(self, ctx): - """Change strike existance policy.""" - server = ctx.guild - self.settings.setStrikesPrivate(server.id, not self.settings.getStrikesPrivate(server.id)) - output = '\nStrike existance is now ' + \ - 'private' if self.settings.getStrikesPrivate(server.id) else 'public' - await ctx.send(inline(output)) - - @baduser.command() - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def config(self, ctx): - """Print the baduser configuration.""" - server = ctx.guild - output = 'Punishment roles:\n' - for role_id in self.settings.getPunishmentRoles(server.id): - try: - role = get_role_from_id(self.bot, server, role_id) - output += '\t' + role.name + '\n' - except Exception as e: - pass # Role was deleted - - output += '\nPositive roles:\n' - for role_id in self.settings.getPositiveRoles(server.id): - try: - role = get_role_from_id(self.bot, server, role_id) - output += '\t' + role.name + '\n' - except Exception as e: - pass # Role was deleted - - output += '\nNeutral roles:\n' - for role_id in self.settings.getNeutralRoles(server.id): - try: - role = get_role_from_id(self.bot, server, role_id) - output += '\t' + role.name + '\n' - except Exception as e: - pass # Role was deleted - - output += '\nStrike contents are private' - output += '\nStrike existence is ' + \ - ('private' if self.settings.getStrikesPrivate(server.id) else 'public') - - await ctx.send(box(output)) - - @baduser.command(name="strikes") - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def strikes(self, ctx, user: discord.User): - """Print the strike count for a user.""" - strikes = self.settings.countUserStrikes(ctx.guild.id, user.id) - await ctx.send(box('User {} has {} strikes'.format(user.name, strikes))) - - @baduser.command() - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def addstrike(self, ctx, user: discord.User, *, strike_text: str): - """Manually add a strike to a user.""" - timestamp = str(ctx.message.created_at)[:-7] - msg = 'Manually added by {} ({}): {}'.format( - ctx.author.name, timestamp, strike_text) - server_id = ctx.guild.id - self.settings.updateBadUser(server_id, user.id, msg) - strikes = self.settings.countUserStrikes(server_id, user.id) - await ctx.send(box('Done. User {} now has {} strikes'.format(user.name, strikes))) - - @baduser.command() - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def clearstrikes(self, ctx, user: discord.User): - """Clear all strikes for a user.""" - self.settings.clearUserStrikes(ctx.guild.id, user.id) - await ctx.send(box('Cleared strikes for {}'.format(user.name))) - - @baduser.command() - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def printstrikes(self, ctx, user: discord.User): - """Print all strikes for a user.""" - strikes = self.settings.getUserStrikes(ctx.guild.id, user.id) - if not strikes: - await ctx.send(box('No strikes for {}'.format(user.name))) - return - - for idx, strike in enumerate(strikes): - await ctx.send(inline('Strike {} of {}:'.format(idx + 1, len(strikes)))) - await ctx.send(box(strike)) - - @baduser.command() - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def deletestrike(self, ctx, user: discord.User, strike_num: int): - """Delete a specific strike for a user.""" - strikes = self.settings.getUserStrikes(ctx.guild.id, user.id) - if not strikes or len(strikes) < strike_num: - await ctx.send(box('Strike not found for {}'.format(user.name))) - return - - strike = strikes[strike_num - 1] - strikes.remove(strike) - self.settings.setUserStrikes(ctx.guild.id, user.id, strikes) - await ctx.send(inline('Removed strike {}. User has {} remaining.'.format(strike_num, len(strikes)))) - await ctx.send(box(strike)) - - @baduser.command() - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def report(self, ctx): - """Displays a report of information on bad users for the server.""" - cur_server = ctx.guild - user_id_to_ban_server = defaultdict(list) - user_id_to_baduser_server = defaultdict(list) - error_messages = list() - for server in self.bot.guilds: - if server.id == cur_server.id: - continue - - if self.settings.getStrikesPrivate(server.id): - error_messages.append("Server '{}' set its strikes private".format(server.name)) - continue - - try: - ban_list = await self.bot.get_bans(server) - except: - ban_list = list() - error_messages.append("Server '{}' refused access to ban list".format(server.name)) - - for user in ban_list: - user_id_to_ban_server[user.id].append(server.id) - - baduser_list = self.settings.getBadUsers(server.id) - for user_id in baduser_list: - user_id_to_baduser_server[user_id].append(server.id) - - bad_users = self.settings.getBadUsers(cur_server.id) - - baduser_entries = list() - otheruser_entries = list() - - for member in cur_server.members: - local_strikes = self.settings.getUserStrikes(cur_server.id, member.id) - other_baduser_servers = user_id_to_baduser_server[member.id] - other_banned_servers = user_id_to_ban_server[member.id] - - if not len(local_strikes) and not len(other_baduser_servers) and not len(other_banned_servers): - continue - - tmp_msg = "{} ({})".format(member.name, member.id) - if other_baduser_servers: - tmp_msg += "\n\tbad user in {} other servers".format(len(other_baduser_servers)) - if other_banned_servers: - tmp_msg += "\n\tbanned from {} other servers".format(len(other_banned_servers)) - - if len(local_strikes): - tmp_msg += "\n\t{} strikes in this server".format(len(local_strikes)) - for strike in local_strikes: - tmp_msg += "\n\t\t{}".format(strike.splitlines()[0]) - baduser_entries.append(tmp_msg) - else: - otheruser_entries.append(tmp_msg) - - other_server_count = len(self.bot.guilds) - 1 - other_ban_count = len([x for x, l in user_id_to_ban_server.items() if len(l)]) - other_baduser_count = len([x for x, l in user_id_to_baduser_server.items() if len(l)]) - msg = "Across {} other servers, {} users are banned and {} have baduser entries".format( - other_server_count, other_ban_count, other_baduser_count) - - msg += "\n\n{} baduser entries for this server".format(len(baduser_entries)) - msg += "\n" + "\n".join(baduser_entries) - msg += "\n\n{} entries for users with no record in this server".format( - len(otheruser_entries)) - msg += "\n" + "\n".join(otheruser_entries) - - if error_messages: - msg += "\n\nSome errors occurred:" - msg += "\n" + "\n".join(error_messages) - - for page in pagify(msg): - await ctx.send(box(page)) - - @baduser.command() - @checks.is_owner() - async def addban(self, ctx, user_id: int, *, reason: str): - self.settings.addBannedUser(user_id, reason) - await ctx.send(inline('Done')) - - @baduser.command() - @checks.is_owner() - async def rmban(self, ctx, user_id: int): - user_id = str(user_id) - self.settings.rmBannedUser(user_id) - await ctx.send(inline('Done')) - - @baduser.command() - @commands.guild_only() - @checks.is_owner() - async def globalcheckbanlist(self, ctx): - bans = await self._load_banned_users() - msg = 'Checking for banned users in {} guilds'.format(len(self.bot.guilds)) - for cur_server in self.bot.guilds: - msg += await self._check_ban_list(cur_server) - - for page in pagify(msg): - await ctx.send(box(page)) - - @baduser.command() - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def checkbanlist(self, ctx): - msg = 'Checking for banned users' - msg += await self._check_ban_list(ctx.guild) - await ctx.send(box(msg)) - - async def _check_ban_list(self, server: discord.Guild): - # external_ban_list is [user_id] - # local_ban_list is {user.id : reason} - external_bans = await self._load_banned_users() - local_bans = self.settings.bannedUsers() - msg = '\n\tChecking {}'.format(server.name) - for member in server.members: - if member.id in external_bans: - msg += '\n\t\tExternal banned user: {} ({})'.format(member.name, member.id) - if member.id in local_bans: - msg += '\n\t\tLocal banned user: {} ({}) for: {}'.format( - member.name, member.id, local_bans[member.id]) - return msg - - @commands.Cog.listener('on_message') - async def mod_message(self, message): - if message.author.id == self.bot.user.id or isinstance(message.channel, discord.abc.PrivateChannel): - return - - author = message.author - content = message.clean_content - channel = message.channel - timestamp = str(message.created_at)[:-7] - log_msg = '[{}] {} ({}): {}/{}'.format(timestamp, author.name, - author.id, channel.name, content) - self.logs[author.id].append(log_msg) - - @commands.Cog.listener('on_member_ban') - async def mod_ban(self, guild, user): - await self.recordBadUser(user, 'BANNED') - - @commands.Cog.listener('on_member_remove') - async def mod_user_left(self, member): - strikes = self.settings.countUserStrikes(member.guild.id, member.id) - if strikes: - msg = 'FYI: A user with {} strikes just left the server: {}'.format( - strikes, member.name) - update_channel = self.settings.getChannel(member.guild.id) - if update_channel is not None: - channel_obj = member.guild.get_channel(update_channel) - await channel_obj.send(msg) - - @commands.Cog.listener('on_member_join') - async def mod_user_join(self, member): - update_channel = self.settings.getChannel(member.guild.id) - if update_channel is None: - return - - channel_obj = member.guild.get_channel(update_channel) - strikes = self.settings.countUserStrikes(member.guild.id, member.id) - if strikes: - msg = 'Hey @here a user with {} strikes just joined the server: {}'.format( - strikes, member.mention) - await channel_obj.send(msg) - - local_ban = self.settings.bannedUsers().get(member.id, None) - if local_ban: - msg = 'Hey @here locally banned user {} (for: {}) just joined the server'.format( - member.mention, local_ban) - await channel_obj.send(msg) - - @commands.Cog.listener('on_member_update') - async def check_punishment(self, before, after): - if before.roles == after.roles: - return - - new_roles = set(after.roles).difference(before.roles) - removed_roles = set(before.roles).difference(after.roles) - - bad_role_ids = self.settings.getPunishmentRoles(after.guild.id) - positive_role_ids = self.settings.getPositiveRoles(after.guild.id) - neutral_role_ids = self.settings.getNeutralRoles(after.guild.id) - - for role in new_roles: - if role.id in bad_role_ids: - await self.recordBadUser(after, role.name) - return - - if role.id in positive_role_ids: - await self.recordRoleChange(after, role.name, True) - return - - if role.id in neutral_role_ids: - await self.recordRoleChange(after, role.name, True, send_ping=False) - return - - for role in removed_roles: - if role.id in positive_role_ids: - await self.recordRoleChange(after, role.name, False) - return - if role.id in neutral_role_ids: - await self.recordRoleChange(after, role.name, False, send_ping=False) - return - - async def recordBadUser(self, member, role_name): - latest_messages = self.logs.get(member.id, "") - msg = 'Name={} Nick={} ID={} Joined={} Role={}\n'.format( - member.name, member.nick, member.id, member.joined_at, role_name) - msg += '\n'.join(latest_messages) - self.settings.updateBadUser(member.guild.id, member.id, msg) - strikes = self.settings.countUserStrikes(member.guild.id, member.id) - - update_channel = self.settings.getChannel(member.guild.id) - if update_channel is not None: - channel_obj = member.guild.get_channel(update_channel) - await channel_obj.send(inline('Detected bad user')) - await channel_obj.send(box(msg)) - await channel_obj.send('Hey @here please leave a note explaining why this user is punished') - await channel_obj.send('This user now has {} strikes'.format(strikes)) - - try: - dm_msg = ('You were assigned the punishment role "{}" in the server "{}".\n' - 'The Mods will contact you shortly regarding this.\n' - 'Attempting to clear this role yourself will result in punishment.').format(role_name, - member.guild.name) - await member.send(box(dm_msg)) - await channel_obj.send('User successfully notified') - except Exception as e: - await channel_obj.send('Failed to notify the user! I might be blocked\n' + box(str(e))) - - async def recordRoleChange(self, member, role_name, is_added, send_ping=True): - msg = 'Detected role {} : Name={} Nick={} ID={} Joined={} Role={}'.format( - "Added" if is_added else "Removed", member.name, member.nick, member.id, member.joined_at, role_name) - - update_channel = self.settings.getChannel(member.guild.id) - if update_channel is not None: - channel_obj = member.guild.get_channel(update_channel) - try: - await channel_obj.send(inline(msg)) - if send_ping: - await channel_obj.send('Hey @here please leave a note explaining why this role was modified') - except: - print('Failed to notify in', update_channel, msg) - - -class BadUserSettings(CogSettings): - def make_default_settings(self): - config = { - 'servers': {}, - 'banned_users': {} - } - return config - - def serverConfigs(self): - return self.bot_settings['servers'] - - def getServer(self, server_id): - configs = self.serverConfigs() - if server_id not in configs: - configs[server_id] = {} - return configs[server_id] - - def getBadUsers(self, server_id): - server = self.getServer(server_id) - if 'badusers' not in server: - server['badusers'] = {} - return server['badusers'] - - def getPunishmentRoles(self, server_id): - server = self.getServer(server_id) - if 'role_ids' not in server: - server['role_ids'] = [] - return server['role_ids'] - - def addPunishmentRole(self, server_id, role_id): - role_ids = self.getPunishmentRoles(server_id) - if role_id not in role_ids: - role_ids.append(role_id) - self.save_settings() - - def rmPunishmentRole(self, server_id, role_id): - role_ids = self.getPunishmentRoles(server_id) - if role_id in role_ids: - role_ids.remove(role_id) - self.save_settings() - - def getPositiveRoles(self, server_id): - server = self.getServer(server_id) - if 'positive_role_ids' not in server: - server['positive_role_ids'] = [] - return server['positive_role_ids'] - - def addPositiveRole(self, server_id, role_id): - role_ids = self.getPositiveRoles(server_id) - if role_id not in role_ids: - role_ids.append(role_id) - self.save_settings() - - def rmPositiveRole(self, server_id, role_id): - role_ids = self.getPositiveRoles(server_id) - if role_id in role_ids: - role_ids.remove(role_id) - self.save_settings() - - def addNeutralRole(self, server_id, role_id): - role_ids = self.getNeutralRoles(server_id) - if role_id not in role_ids: - role_ids.append(role_id) - self.save_settings() - - def getNeutralRoles(self, server_id): - server = self.getServer(server_id) - if 'neutral_role_ids' not in server: - server['neutral_role_ids'] = [] - return server['neutral_role_ids'] - - def rmNeutralRole(self, server_id, role_id): - role_ids = self.getNeutralRoles(server_id) - if role_id in role_ids: - role_ids.remove(role_id) - self.save_settings() - - def updateBadUser(self, server_id, user_id, msg): - badusers = self.getBadUsers(server_id) - if user_id not in badusers: - badusers[user_id] = [] - - badusers[user_id].append(msg) - self.save_settings() - - def countUserStrikes(self, server_id, user_id): - badusers = self.getBadUsers(server_id) - if user_id not in badusers: - return 0 - else: - return len(badusers[user_id]) - - def setUserStrikes(self, server_id, user_id, strikes): - badusers = self.getBadUsers(server_id) - badusers[user_id] = strikes - self.save_settings() - - def clearUserStrikes(self, server_id, user_id): - badusers = self.getBadUsers(server_id) - badusers.pop(user_id, None) - self.save_settings() - - def getUserStrikes(self, server_id, user_id): - badusers = self.getBadUsers(server_id) - return badusers.get(user_id, []) - - def updateChannel(self, server_id, channel_id): - server = self.getServer(server_id) - if channel_id is None: - if 'update_channel' in server: - server.pop('update_channel') - self.save_settings() - return - - server['update_channel'] = channel_id - self.save_settings() - - def getChannel(self, server_id): - server = self.getServer(server_id) - return server.get('update_channel') - - def getStrikesPrivate(self, server_id): - server = self.getServer(server_id) - return server.get('strikes_private', False) - - def setStrikesPrivate(self, server_id, strikes_private): - server = self.getServer(server_id) - server['strikes_private'] = strikes_private - self.save_settings() - - def bannedUsers(self): - return self.bot_settings['banned_users'] - - def addBannedUser(self, user_id: int, reason: str): - self.bannedUsers()[user_id] = reason - self.save_settings() - - def rmBannedUser(self, user_id: int): - self.bannedUsers().pop(user_id, None) - self.save_settings() diff --git a/calculator/__init__.py b/calculator/__init__.py deleted file mode 100644 index 78f2ca51..00000000 --- a/calculator/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .calculator import * - - -def setup(bot): - bot.add_cog(Calculator(bot)) diff --git a/calculator/calculator.py b/calculator/calculator.py deleted file mode 100644 index 899d80b3..00000000 --- a/calculator/calculator.py +++ /dev/null @@ -1,111 +0,0 @@ -""" -A cloned and improved version of paddo's calculator cog. -""" - -import numbers -import os -import re -import shlex -import subprocess -import sys - -import discord -from redbot.core import commands, Config -from redbot.core.utils.chat_formatting import inline, box -from functools import reduce - -from rpadutils.rpadutils import CtxIO - -ACCEPTED_TOKENS = r'[\[\]\-()*+/0-9=.,% |&<>~_^]|>=|<=|==|!=|factorial|randrange|isfinite|copysign|radians|isclose|degrees|randint|lgamma|choice|random|round|log1p|log10|ldexp|isnan|isinf|hypot|gamma|frexp|floor|expm1|atanh|atan2|asinh|acosh|False|range|tanh|sqrt|sinh|modf|log2|fmod|fabs|erfc|cosh|ceil|atan|asin|acos|else|True|fsum|tan|sin|pow|nan|log|inf|gcd|sum|exp|erf|cos|for|not|and|ans|pi|in|is|or|if|e|x' - -ALTERED_TOKENS = {'^': '**', '_':'ans'} - -HELP_MSG = ''' -This calculator works by first validating the content of your query against a whitelist, and then -executing a python eval() on it, so some common syntax wont work. Here is the full symbol whitelist: -''' - - -class Calculator(commands.Cog): - def __init__(self, bot, *args, **kwargs): - super().__init__(*args, **kwargs) - self.bot = bot - self.config = Config.get_conf(self, identifier=857907) - self.config.register_user(ans={}) - - """ - CONFIG: Config - | GUILDS: Config - | | ans: channel_id -> Any - """ - @commands.group() - async def helpcalc(self, ctx): - '''Whispers info on how to use the calculator.''' - help_msg = HELP_MSG + '\n' + ACCEPTED_TOKENS - await ctx.author.send(box(help_msg)) - - @commands.command(aliases=['calc']) - async def calculator(self, ctx, *, inp): - '''Evaluate equations. Use helpcalc for more info.''' - inp = inp.lower() - unaccepted = list(filter(None, re.split(ACCEPTED_TOKENS, inp))) - for token in ALTERED_TOKENS: - inp = inp.replace(token, ALTERED_TOKENS[token]) - - if unaccepted: - err_msg = 'Found unexpected symbols inside the input: {}'.format(", ".join(unaccepted)) - help_msg = 'Use {0.prefix}helpcalc for info on how to use this command' - await ctx.send(inline(err_msg + '\n' + help_msg.format(ctx))) - return - - ans = (await self.config.user(ctx.author).ans()).get(str(ctx.channel.id)) - if re.search(r'\bans\b', inp) and ans is None: - await ctx.send("You don't have a previous result saved.") - return - - cmd = """{} -c "from math import *;from random import *;ans = {};print(eval('{}'), end='', flush=True)" """.format( - sys.executable, ans, inp) - - try: - if os.name != 'nt' and sys.platform != 'win32': - cmd = shlex.split(cmd) - output = subprocess.check_output(cmd, stderr=subprocess.STDOUT, timeout=2) - calc_result = output.decode('utf-8').strip() - except subprocess.TimeoutExpired: - await ctx.send(inline('Command took too long to execute. Quit trying to break the bot.')) - return - except subprocess.CalledProcessError as e: - await ctx.send(inline(e.output.decode("utf-8").strip().split('\n')[-1])) - return - - - if len(str(calc_result)) > 1024: - await ctx.send(inline("The result is obnoxiously long! Try a request under 1k characters!")) - elif len(str(calc_result)) > 0: - if isinstance(calc_result, numbers.Number): - if calc_result > 1: - calc_result = round(calc_result, 3) - - em = discord.Embed(color=discord.Color.greyple()) - em.add_field(name='Input', value='`{}`'.format(inp)) - em.add_field(name='Result', value=calc_result) - async with self.config.user(ctx.author).ans() as ans: - if calc_result is not None: - ans[ctx.channel.id] = calc_result - await ctx.send(embed=em) - - @commands.command() - async def add(self, ctx, *, inp): - """Adds a string of numbers""" - em = discord.Embed(color=discord.Color.greyple()) - em.add_field(name='Input', value='`{}`'.format('+'.join(filter(None,re.split(r'\D', inp))))) - em.add_field(name='Result', value=sum(map(lambda x: int('0'+x), re.split(r'\D', inp)))) - await ctx.send(embed=em) - - @commands.command() - async def multiply(self, ctx, *, inp): - """Multiplies a string of numbers""" - em = discord.Embed(color=discord.Color.greyple()) - em.add_field(name='Input', value='`{}`'.format('*'.join(filter(None,re.split(r'\D', inp))))) - em.add_field(name='Result', value=reduce(lambda x, y: x*int(y) if y else x, re.split(r'\D', inp), 1)) - await ctx.send(embed=em) diff --git a/channelmod/__init__.py b/channelmod/__init__.py deleted file mode 100644 index a2598279..00000000 --- a/channelmod/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .channelmod import * - - -def setup(bot): - n = ChannelMod(bot) - bot.add_cog(n) diff --git a/channelmod/channelmod.py b/channelmod/channelmod.py deleted file mode 100644 index 443980dc..00000000 --- a/channelmod/channelmod.py +++ /dev/null @@ -1,378 +0,0 @@ -import io -import re -import logging -import time -import traceback -from datetime import datetime - -import aiohttp -import discord -from redbot.core import checks -from redbot.core import commands -from redbot.core.utils.chat_formatting import inline - -from rpadutils import CogSettings, box - -# Three hour cooldown -ATTRIBUTION_TIME_SECONDS = 60 * 60 * 3 - -frMESSAGE_LINK = r'(https://discordapp\.com/channels/{0.guild.id}/{0.id}/(\d+)/?)' -MESSAGE_LINK = 'https://discordapp.com/channels/{0.guild.id}/{0.channel.id}/{0.id}' - - -class ChannelMod(commands.Cog): - """Channel moderation tools.""" - - def __init__(self, bot, *args, **kwargs): - super().__init__(*args, **kwargs) - self.bot = bot - self.settings = ChannelModSettings("channelmod") - self.channel_last_spoke = {} - - @commands.group() - async def channelmod(self, ctx): - """Manage channel moderation settings""" - - @channelmod.command() - @checks.is_owner() - async def addmirror(self, ctx, source_channel_id: int, dest_channel_id: int, docheck: bool = True): - """Set mirroring between two channels.""" - if docheck and (not self.bot.get_channel(source_channel_id) or not self.bot.get_channel(dest_channel_id)): - await ctx.send(inline('Check your channel IDs, or maybe the bot is not in those servers')) - return - self.settings.add_mirrored_channel(source_channel_id, dest_channel_id) - await ctx.send(inline('Done')) - - @channelmod.command() - @checks.is_owner() - async def rmmirror(self, ctx, source_channel_id: int, dest_channel_id: int, docheck: bool = True): - """Remove mirroring between two channels.""" - if docheck and (not self.bot.get_channel(source_channel_id) or not self.bot.get_channel(dest_channel_id)): - await ctx.send(inline('Check your channel IDs, or maybe the bot is not in those servers')) - return - self.settings.rm_mirrored_channel(source_channel_id, dest_channel_id) - await ctx.send(inline('Done')) - - @channelmod.command() - @checks.is_owner() - async def mirrorconfig(self, ctx): - """List mirror config.""" - mirrored_channels = self.settings.mirrored_channels() - msg = 'Mirrored channels\n' - for mc_id, config in mirrored_channels.items(): - channel = self.bot.get_channel(mc_id) - channel_name = channel.name if channel else 'unknown' - msg += '\n{} ({})'.format(mc_id, channel_name) - for channel_id in config['channels']: - channel = self.bot.get_channel(channel_id) - channel_name = channel.name if channel else 'unknown' - msg += '\n\t{} ({})'.format(channel_id, channel_name) - await ctx.send(box(msg)) - - @channelmod.command() - async def countreactions(self, ctx, message: discord.Message): - """Count reactions on a message and all of its mirrors. - - The message can be a link, a message id (if used in the same - channel as the message), or the channel_id and message_id - separated by a dash (channel_id-message-id)""" - mirrored_messages = self.settings.get_mirrored_messages(message.channel.id, message.id) - if not mirrored_messages: - await ctx.send("This message isn't mirrored!") - reacts = {} - for react in message.reactions: - reacts[str(react)] = react.count - 1 - for (chid, mid) in mirrored_messages: - dest_channel = self.bot.get_channel(chid) - if not dest_channel: - print('could not locate channel {}'.format(chid)) - continue - dest_message = await dest_channel.fetch_message(mid) - if not dest_message: - print('could not locate message {}'.format(mid)) - continue - dest_reaction = discord.utils.find(lambda r: r == react, dest_message.reactions) - if not dest_reaction: - print('could not locate reaction {}'.format(react)) - continue - reacts[str(react)] += dest_reaction.count - 1 - o = "" - maxlen = len(str(max(reacts.values(), key=lambda x: len(str(x))))) - for r, c in reacts.items(): - o += "{{}}: {{:{}}}\n".format(maxlen).format(r,c) - await ctx.send(o) - - - - - - - - @commands.Cog.listener('on_message') - async def mirror_msg(self, message): - if message.author.id == self.bot.user.id: - return - - prefixes = await self.bot.get_prefix(message) - for prefix in prefixes if isinstance(prefixes, list) else [prefixes]: - if message.content.strip().startswith(prefix): - return - - channel = message.channel - mirrored_channels = self.settings.get_mirrored_channels(channel.id) - - if not mirrored_channels: - return - - last_spoke, last_spoke_timestamp = self.settings.get_last_spoke(channel.id) - now_time = datetime.utcnow() - last_spoke_time = datetime.utcfromtimestamp( - last_spoke_timestamp) if last_spoke_timestamp else now_time - attribution_required = last_spoke != message.author.id - attribution_required |= (now_time - last_spoke_time).total_seconds() > ATTRIBUTION_TIME_SECONDS - self.settings.set_last_spoke(channel.id, message.author.id) - - attachment_bytes = None - filename = None - if message.attachments: - # If we know we're copying a message and that message has an attachment, - # pre download it and reuse it for every upload. - attachment = message.attachments[0] - if hasattr(attachment, 'url') and hasattr(attachment, 'filename'): - url = attachment.url - filename = attachment.filename - async with aiohttp.ClientSession() as session: - async with session.get(url) as response: - attachment_bytes = io.BytesIO(await response.read()) - - for dest_channel_id in mirrored_channels: - try: - dest_channel = self.bot.get_channel(dest_channel_id) - if not dest_channel: - continue - - if attribution_required: - msg = 'Posted by **{}** in *{} - #{}*:'.format(message.author.name, - message.guild.name, - message.channel.name) - await dest_channel.send(msg) - await dest_channel.send(message.jump_url) - - fmessage = await self.mformat(message.content, message.channel, dest_channel) - - if attachment_bytes and filename: - dest_message = await dest_channel.send(file=discord.File(attachment_bytes, filename), - content=fmessage) - attachment_bytes.seek(0) - elif message.content: - dest_message = await dest_channel.send(fmessage) - else: - print('Failed to mirror message from', channel.id, 'no action to take') - continue - - self.settings.add_mirrored_message( - channel.id, message.id, dest_channel.id, dest_message.id) - except Exception as ex: - print('Failed to mirror message from ', channel.id, 'to', dest_channel_id, ':', ex) - traceback.print_exc() - - if attachment_bytes: - attachment_bytes.close() - - @commands.Cog.listener('on_message_edit') - async def mirror_msg_edit(self, message, new_message): - await self.mirror_msg_mod(message, new_message_content=new_message.content) - - @commands.Cog.listener('on_message_delete') - async def mirror_msg_delete(self, message): - await self.mirror_msg_mod(message, delete_message_content=True) - - @commands.Cog.listener('on_reaction_add') - async def mirror_reaction_add(self, reaction, user): - message = reaction.message - if message.author.id != user.id: - return - await self.mirror_msg_mod(message, new_message_reaction=reaction.emoji) - - @commands.Cog.listener('on_reaction_remove') - async def mirror_reaction_remove(self, reaction, user): - message = reaction.message - if message.author.id != user.id: - return - await self.mirror_msg_mod(message, delete_message_reaction=reaction.emoji) - - async def mirror_msg_mod(self, message, - new_message_content: str = None, - delete_message_content: bool = False, - new_message_reaction=None, - delete_message_reaction=None): - if message.author.id == self.bot.user.id or isinstance(message.channel, discord.abc.PrivateChannel): - return - - channel = message.channel - mirrored_messages = self.settings.get_mirrored_messages(channel.id, message.id) - for (dest_channel_id, dest_message_id) in mirrored_messages: - try: - dest_channel = self.bot.get_channel(dest_channel_id) - if not dest_channel: - print('could not locate channel to mod') - continue - dest_message = await dest_channel.fetch_message(dest_message_id) - if not dest_message: - print('could not locate message to mod') - continue - - if new_message_content: - fcontent = await self.mformat(new_message_content, channel, dest_message.channel) - await dest_message.edit(content=fcontent) - elif new_message_reaction: - await dest_message.add_reaction(new_message_reaction) - elif delete_message_content: - await dest_message.delete() - elif delete_message_reaction: - await dest_message.remove_reaction(delete_message_reaction, dest_message.guild.me) - except Exception as ex: - print('Failed to mirror message edit from ', - channel.id, 'to', dest_channel_id, ':', ex) - - - async def mformat(self, text, from_channel, dest_channel): - # LINKS - for link, mid in re.findall(frMESSAGE_LINK.format(from_channel), text): - from_link = await from_channel.fetch_message(mid) - if not from_link: - print('could not locate link to copy') - continue - mirrored_messages = self.settings.get_mirrored_messages(from_link.channel.id, from_link.id) - to_link_id = [dmid for dcid, dmid in mirrored_messages if dcid == dest_channel.id] - if not to_link_id: - print('could not locate link to mod') - continue - to_link_id = to_link_id[0] - dest_link = await dest_channel.fetch_message(to_link_id) - - newlink = MESSAGE_LINK.format(dest_link) - text = text.replace(link, newlink) - # ROLES - for rtext, rid in re.findall(r'(<@&(\d+)>)', text): - target = from_channel.guild.get_role(int(rid)) - if target is None: - print('could not locate role to mod') - continue - dest = discord.utils.get(dest_channel.guild.roles, name=target.name) - if dest is None: - repl = "\\@"+target.name - else: - repl = "<@&{}>".format(dest.id) - text = text.replace(rtext, repl) - # MENTIONS - for utext, uid in re.findall(r'(<@!(\d+)>)', text): - target = from_channel.guild.get_member(int(uid)) - if target is None: - print('could not locate user to mod') - continue - text = text.replace(utext, target.name) - # CHANNELS - for ctext, cid in re.findall(r'(<#(\d+)>)', text): - target = from_channel.guild.get_channel(int(cid)) - if target is None: - print('could not locate channel to mod') - continue - text = text.replace(ctext, "\\#"+target.name) - #EVERYONE - text = re.sub(r"@everyone\b", "@\u200beveryone", text) - text = re.sub(r"@here\b", "@\u200bhere", text) - return text - -class ChannelModSettings(CogSettings): - def make_default_settings(self): - config = { - 'servers': {}, - 'mirrored_channels': {}, - 'max_mirrored_messages': 20, - } - return config - - def servers(self): - return self.bot_settings['servers'] - - def get_server(self, server_id: int): - servers = self.servers() - if server_id not in servers: - servers[server_id] = {} - return servers[server_id] - - def max_mirrored_messages(self): - return self.bot_settings['max_mirrored_messages'] - - def mirrored_channels(self): - # Mirrored channels looks like: - # : { - # 'last_spoke: '', - # 'channels': [dest_channel_id_1, dest_channel_id2], - # 'messages': { - # : [ - # (dest_channel_id, dest_channel_msg], - # ] - # } - # } - return self.bot_settings['mirrored_channels'] - - def get_mirrored_channels(self, source_channel: str): - return self.mirrored_channels().get(source_channel, {}).get('channels', []) - - def get_last_spoke(self, source_channel: str): - spoke_values = self.mirrored_channels().get(source_channel, {}) - return spoke_values.get('last_spoke', None), spoke_values.get('last_spoke_timestamp', None) - - def set_last_spoke(self, source_channel: str, last_spoke: str): - spoke_values = self.mirrored_channels().get(source_channel) - spoke_values['last_spoke'] = last_spoke - spoke_values['last_spoke_timestamp'] = time.time() - self.save_settings() - - def add_mirrored_channel(self, source_channel: int, dest_channel: int): - channels = self.mirrored_channels() - if source_channel == dest_channel: - raise commands.UserFeedbackCheckFailure('Cannot mirror a channel to itself') - if dest_channel in channels: - raise commands.UserFeedbackCheckFailure('Destination channel is already a source channel') - if source_channel not in channels: - channels[source_channel] = { - 'channels': [], - 'messages': {}, - } - channels[source_channel]['channels'].append(dest_channel) - self.save_settings() - - def rm_mirrored_channel(self, source_channel: int, dest_channel: int): - channels = self.mirrored_channels() - config = channels.get(source_channel) - if config and dest_channel in config['channels']: - dest_channel_config = config['channels'] - dest_channel_config.remove(dest_channel) - if not dest_channel_config: - channels.pop(source_channel) - self.save_settings() - - def add_mirrored_message(self, source_channel: int, source_message: str, dest_channel: int, dest_message: str): - channel_config = self.mirrored_channels()[source_channel] - messages = channel_config['messages'] - if source_message not in messages: - messages[source_message] = [] - - targets = messages[source_message] - new_entry = [dest_channel, dest_message] - if new_entry not in targets: - targets.append(new_entry) - self.save_settings() - - if len(messages) > self.max_mirrored_messages(): - oldest_msg = min(messages.keys()) - messages.pop(oldest_msg) - self.save_settings() - - def get_mirrored_messages(self, source_channel: int, source_message: str): - """Returns [(channel_id, message_id), ...]""" - channel_config = self.mirrored_channels().get(source_channel, {}) - return channel_config.get('messages', {}).get(source_message, []) diff --git a/chronomagia/__init__.py b/chronomagia/__init__.py deleted file mode 100644 index 4ad398ce..00000000 --- a/chronomagia/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from .chronomagia import * - - -def setup(bot): - n = ChronoMagia(bot) - bot.add_cog(n) - bot.loop.create_task(n.reload_cm_task()) diff --git a/chronomagia/chronomagia.py b/chronomagia/chronomagia.py deleted file mode 100644 index 456634f9..00000000 --- a/chronomagia/chronomagia.py +++ /dev/null @@ -1,167 +0,0 @@ -import asyncio -import csv -import difflib -import os -import traceback -import urllib.parse -from collections import OrderedDict - -import discord -from redbot.core import commands, data_manager -from redbot.core.bot import Red -from redbot.core.utils.chat_formatting import inline - -import rpadutils -from rpadutils import Menu, EmojiUpdater - - -def _data_file(file_name: str) -> str: - return os.path.join(str(data_manager.cog_data_path(raw_name='padglobal')), file_name) - - -SUMMARY_SHEET = 'https://docs.google.com/spreadsheets/d/e/2PACX-1vQsO9Xi9cKaUQWPvDjjIKpHotZ036LCTN66PuNoQwvb8qZi4LmEUEOYmHDyqUJUzghI28aPrQHfRSYd/pub?gid=1488138129&single=true&output=csv' -PIC_URL = 'https://storage.googleapis.com/mirubot-chronomagia/cards/{}.png' - - -class CmCard(object): - def __init__(self, csv_row): - row = [x.strip() for x in csv_row] - self.name = row[0] - self.name_clean = clean_name_for_query(self.name) - self.expansion = row[12] - self.rarity = row[1] - self.monspell = row[2] - self.cost = row[3] - self.type1 = row[4] - self.type2 = row[5] - self.atk = row[6] - self.defn = row[7] - self.atkeff = row[9] - self.cardeff = row[11] - - -class ChronoMagia(commands.Cog): - """ChronoMagia.""" - - def __init__(self, bot: Red, *args, **kwargs): - super().__init__(*args, **kwargs) - self.bot = bot - self.card_data = [] - self.menu = Menu(bot) - self.id_emoji = '\N{INFORMATION SOURCE}' - self.pic_emoji = '\N{FRAME WITH PICTURE}' - - async def reload_cm_task(self): - await self.bot.wait_until_ready() - while self == self.bot.get_cog('ChronoMagia'): - try: - await self.refresh_data() - print('Done refreshing ChronoMagia') - except Exception as ex: - print("reload CM loop caught exception " + str(ex)) - traceback.print_exc() - await asyncio.sleep(60 * 60 * 1) - - async def refresh_data(self): - await self.bot.wait_until_ready() - - standard_expiry_secs = 2 * 60 * 60 - summary_text = await rpadutils.makeAsyncCachedPlainRequest( - _data_file('summary.csv'), SUMMARY_SHEET, standard_expiry_secs) - file_reader = csv.reader(summary_text.splitlines(), delimiter=',') - next(file_reader, None) # skip header - self.card_data.clear() - for row in file_reader: - if not row or not row[0].strip(): - # Ignore empty rows - continue - if len(row) < 11: - print('bad row: ', row) - self.card_data.append(CmCard(row)) - - @commands.command() - async def cmid(self, ctx, *, query: str): - """ChronoMagia query.""" - query = clean_name_for_query(query) - if len(query) < 3: - await ctx.send(inline('query must be at least 3 characters')) - return - - names_to_card = {x.name_clean: x for x in self.card_data} - - # Check if the card name starts with the query - matches = list(filter(lambda x: x.startswith(query), names_to_card.keys())) - - # Find a card that closely matches the query - if not matches: - matches = difflib.get_close_matches(query, names_to_card.keys(), n=1, cutoff=.6) - - # Find a card that contains the query text - if not matches: - matches = list(filter(lambda x: query in x, names_to_card.keys())) - - if matches: - await self.do_menu(ctx, names_to_card[matches[0]]) - else: - await ctx.send(inline('no matches')) - - async def do_menu(self, ctx, c): - emoji_to_embed = OrderedDict() - emoji_to_embed[self.id_emoji] = make_embed(c) - emoji_to_embed[self.pic_emoji] = make_img_embed(c) - return await self._do_menu(ctx, self.id_emoji, emoji_to_embed) - - async def _do_menu(self, ctx, starting_menu_emoji, emoji_to_embed): - remove_emoji = self.menu.emoji['no'] - emoji_to_embed[remove_emoji] = self.menu.reaction_delete_message - - try: - result_msg, result_embed = await self.menu.custom_menu(ctx, EmojiUpdater(emoji_to_embed), - starting_menu_emoji, timeout=20) - if result_msg and result_embed: - # Message is finished but not deleted, clear the footer - result_embed.set_footer(text=discord.Embed.Empty) - await result_msg.edit(embed=result_embed) - except Exception as ex: - print('Menu failure', ex) - - -def make_base_embed(c: CmCard): - embed = discord.Embed() - embed.title = c.name - embed.set_footer(text='Requester may click the reactions below to switch tabs') - return embed - - -def make_embed(c: CmCard): - embed = make_base_embed(c) - - embed.add_field( - name=c.monspell, value='{}\nCost {}'.format(c.rarity, c.cost), inline=True) - if c.monspell == 'Monster': - mtype = '\n{}/{} '.format(c.type1, c.type2) if c.type2 else '{} '.format(c.type1) - embed.add_field(name=mtype, value='Atk {}\nDef {}'.format(c.atk, c.defn), inline=True) - if c.expansion: - embed.add_field(name='Expansion', value=c.expansion, inline=False) - - if c.atkeff: - embed.add_field(name='Attack Effect', value=c.atkeff, inline=False) - - if c.cardeff: - embed.add_field(name='Card Effect', value=c.cardeff, inline=False) - else: - embed.add_field(name='Card Effect', value=c.cardeff, inline=True) - - return embed - - -def make_img_embed(c: CmCard): - embed = make_base_embed(c) - url = PIC_URL.format(urllib.parse.quote(c.name)) - print(url) - embed.set_image(url=url) - return embed - - -def clean_name_for_query(name: str): - return name.strip().lower().replace(',', '') diff --git a/crowddata/__init__.py b/crowddata/__init__.py new file mode 100644 index 00000000..71856a25 --- /dev/null +++ b/crowddata/__init__.py @@ -0,0 +1,7 @@ +from .crowddata import CrowdData + +__red_end_user_data_statement__ = "All explicitly stored user preferences are kept persistantly." + + +async def setup(bot): + bot.add_cog(CrowdData(bot)) if not __import__('asyncio').iscoroutinefunction(bot.add_cog) else await bot.add_cog(CrowdData(bot)) diff --git a/crowddata/crowddata.py b/crowddata/crowddata.py new file mode 100644 index 00000000..23048a18 --- /dev/null +++ b/crowddata/crowddata.py @@ -0,0 +1,50 @@ +import logging +from io import BytesIO +from typing import Any + +from redbot.core import Config +from redbot.core.bot import Red + +from crowddata.menu_map import crowddata_menu_map +from crowddata.mixins.adpem.adpem import AdPEMStats + +logger = logging.getLogger('red.misc-cogs.crowddata') + + +class CrowdData(AdPEMStats): + """Stores user preferences for users.""" + + menu_map = crowddata_menu_map + + def __init__(self, bot: Red, *args, **kwargs): + super().__init__(*args, **kwargs) + self.bot = bot + + self.config = Config.get_conf(self, identifier=720700474) + + self.setup_mixins() + + async def red_get_data_for_user(self, *, user_id): + """Get a user's personal data.""" + data = [] + + data = '\n'.join(await self.get_mixin_user_data(user_id)) + if not data: + data = "No data is stored for user with ID {}.\n".format(user_id) + return {"user_data.txt": BytesIO(data.encode())} + + async def red_delete_data_for_user(self, *, requester, user_id): + """Delete a user's personal data.""" + await self.delete_mixin_user_data(requester, user_id) + + async def register_menu(self): + await self.bot.wait_until_ready() + menulistener: Any = self.bot.get_cog("MenuListener") + if menulistener is None: + logger.warning("MenuListener is not loaded.") + return + await menulistener.register(self) + + async def get_menu_default_data(self, ims): + data = {} + return data diff --git a/crowddata/info.json b/crowddata/info.json new file mode 100644 index 00000000..cdbfcac6 --- /dev/null +++ b/crowddata/info.json @@ -0,0 +1,17 @@ +{ + "author": ["Aradia Megido#2552"], + "description": "Allows users to set up custom preferences to be used cross-cog.", + "end_user_data_statement": "All explicitly stored user preferences are kept persistantly.", + "install_msg": "See for a full list of commands.", + "short": "User Preferences", + "min_bot_version": "3.4.0", + "max_bot_version": "0.0.0", + "min_python_version": [3, 8, 1], + "hidden": false, + "disabled": false, + "required_cogs": { + "dbcog": "https://github.com/TsubakiBotPad/pad-cogs" + }, + "requirements": [], + "tags": [] +} diff --git a/crowddata/menu_map.py b/crowddata/menu_map.py new file mode 100644 index 00000000..7e77dfab --- /dev/null +++ b/crowddata/menu_map.py @@ -0,0 +1,5 @@ +from crowddata.mixins.adpem.menu.closable_embed import ClosableEmbedMenu + +crowddata_menu_map = { + ClosableEmbedMenu.MENU_TYPE: ClosableEmbedMenu +} diff --git a/crowddata/mixins/__init__.py b/crowddata/mixins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/crowddata/mixins/adpem/__init__.py b/crowddata/mixins/adpem/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/crowddata/mixins/adpem/adpem.py b/crowddata/mixins/adpem/adpem.py new file mode 100644 index 00000000..84d38a10 --- /dev/null +++ b/crowddata/mixins/adpem/adpem.py @@ -0,0 +1,311 @@ +from collections import Counter +from datetime import datetime, timezone +from typing import Any + +import time +from discord import Color +from discordmenu.emoji.emoji_cache import emoji_cache +from redbot.core import Config, commands +from redbot.core.bot import Red +from tsutils.cog_mixins import CogMixin +from tsutils.emoji import NO_EMOJI, char_to_emoji +from tsutils.menu.view.closable_embed import ClosableEmbedViewState +from tsutils.query_settings.query_settings import QuerySettings +from tsutils.time import NA_TIMEZONE, NEW_DAY, get_last_time +from tsutils.tsubaki.monster_header import MonsterHeader +from tsutils.user_interaction import get_user_confirmation, get_user_reaction + +from crowddata.mixins.adpem.menu.closable_embed import ClosableEmbedMenu +from crowddata.mixins.adpem.view.show_stats import ShowStatsView, ShowStatsViewProps + +# This is the timestamp of the most recent AdPEM reset +LAST_RESET = datetime(year=2021, month=12, day=24).timestamp() + + +def opted_in(is_opted): + async def check(ctx): + if is_opted == await ctx.bot.get_cog("CrowdData").config.user(ctx.author).opted_in(): + return True + if is_opted and len(ctx.invoked_parents) != 1: + await ctx.send(f"You need to opt in first via `{ctx.prefix}{ctx.invoked_parents[0]} optin`") + return False + + return commands.check(check) + + +class AdPEMStats(CogMixin): + config: Config + bot: Red + + def setup_self(self): + self.config.register_global(pulls=[], valid_lengths=[4]) + self.config.register_user(opted_in=False, accounts=1, skipconf=False) + + async def red_get_data_for_user(self, *, user_id): + num = len([p for p in await self.config.pulls() if p[0] == user_id]) + return f"You have {num} logged pulls." + + async def red_delete_data_for_user(self, *, requester, user_id): + async with self.config.pulls() as pulls: + for pull in pulls: + if pull[0] == user_id: + pulls.remove(pull) + await self.config.user_from_id(user_id).opted_in.set(False) + + @commands.group(aliases=['vem']) + async def adpem(self, ctx): + """Log data for data from the Video Egg Machine""" + + @adpem.group(usage="pull1, pull2, pull3, pull4", invoke_without_command=True) + @opted_in(True) + async def report(self, ctx, *, pulls): + """Report a set of pulls or opt-in.""" + await self.report_at_time(ctx, pulls, 0) + + @adpem.group(usage="pull1, pull2, pull3, pull4", invoke_without_command=True) + @opted_in(True) + async def reportyesterday(self, ctx, *, pulls): + """Report yesterday's pulls""" + await self.report_at_time(ctx, pulls, 24 * 60 * 60) + + async def report_at_time(self, ctx, pulls, offset): + pulls_split = pulls.split(',') + # TODO: support sending pulls throughout the day, and just have a single max # of pulls + if len(pulls_split) > max(await self.config.valid_lengths()): + return await ctx.send(f"Please supply all of your pulls with commas in between them. You can use names or" + f" numbers.\n\t" + f"Valid input: `{ctx.prefix}adpem report 618, 3719, 3600, 3013, 618`\n\t" + f"Valid input: `{ctx.prefix}adpem report Enoch, Facet, D Globe, B Super King, Facet`") + + dbcog: Any = ctx.bot.get_cog("DBCog") + if dbcog is None: + return await ctx.send("Required cogs not loaded. Please alert a bot owner.") + await dbcog.wait_until_ready() + + if not await self.assert_ready(ctx, self.midnight() - offset): + return + + monsters = [await dbcog.find_monster('inadpem ' + pull.strip()) + for pull in pulls_split] + + if not all(monsters): + unknown = '\n\t'.join(s for s, m in zip(pulls_split, monsters) if m is None) + return await ctx.send(f"Not all monsters were valid. The following could not be processed:\n\t{unknown}") + + check = '\n\t'.join(MonsterHeader.menu_title(m, use_emoji=True).to_markdown() + for m in monsters) + confirmation = await get_user_confirmation(ctx, f"Are these monsters correct?\n\t{check}", + timeout=30, force_delete=False, show_feedback=True) + if not confirmation: + if confirmation is None: + await ctx.send(ctx.author.mention + " Submission timed out. Please resubmit and verify.") + return + + await self.add_pull(ctx, [m.monster_id for m in monsters], time.time() - offset) + + @report.command() + async def skip(self, ctx): + """Mark that you skipped your pulls today""" + await self.skipforgot(ctx, 'Skipped', 0) + + @report.command() + async def forgot(self, ctx): + """Mark that you forgot your pulls today""" + await self.skipforgot(ctx, 'Forgot', 0) + + @reportyesterday.command(name="skip") + async def y_skip(self, ctx): + """Mark that you skipped your pulls yesterday""" + await self.skipforgot(ctx, 'Skipped', 24 * 60 * 60) + + @reportyesterday.command(name="forgot") + async def y_forgot(self, ctx): + """Mark that you forgot your pulls yesterday""" + await self.skipforgot(ctx, 'Forgot', 24 * 60 * 60) + + async def skipforgot(self, ctx, ptype: str, offset: int): + if not await self.assert_ready(ctx, self.midnight() - offset): + return + if not await self.config.user(ctx.author).skipconf() and \ + not await get_user_confirmation(ctx, f"Are you sure you want to report that you {ptype.lower()}" + f" {'yesterday' if offset else 'today'}'s pulls on an account?" + f"", force_delete=False, show_feedback=True): + return + await self.add_pull(ctx, ptype, time.time() - offset) + if await self.config.user(ctx.author).skipconf(): + await ctx.tick() + + @adpem.command() + @opted_in(True) + async def remove(self, ctx): + """Remove all of today's pulls""" + await self.remove_at_time(ctx, self.midnight()) + + @adpem.command() + @opted_in(True) + async def removeyesterday(self, ctx): + """Remove all of yesterday's pulls""" + await self.remove_at_time(ctx, self.midnight() - (24 * 60 * 60)) + + async def remove_at_time(self, ctx, midnight): + dbcog: Any = ctx.bot.get_cog("DBCog") + if dbcog is None: + return await ctx.send("Required cogs not loaded. Please alert a bot owner.") + await dbcog.wait_until_ready() + + pulls = [[uid, v, ts] for uid, v, ts in await self.config.pulls() + if midnight <= ts < midnight + (24 * 60 * 60) and uid == ctx.author.id] + pulls = {char_to_emoji(str(c)): vs for c, vs in enumerate(pulls, 1)} + if not pulls: + return await ctx.send("There are no logged pulls during the given time period.") + + pulltext = [] + for c, (uid, pull, ts) in pulls.items(): + if isinstance(pull, str): + pulltext.append(c + ' ' + pull) + else: + pulltext.append(c + '\n\t' + '\n\t'.join( + MonsterHeader.menu_title(dbcog.get_monster(mid), use_emoji=True).to_markdown() + for mid in pull) + ) + pulltext = '\n\n'.join(pulltext) + + chosen = await get_user_reaction(ctx, f"Which set of pulls would you like to remove?\n\n{pulltext}", + *pulls.keys(), NO_EMOJI, force_delete=False, show_feedback=True) + + if chosen is None or chosen == NO_EMOJI: + return + + async with self.config.pulls() as c_pulls: + c_pulls.remove(pulls[chosen]) + await ctx.send("Your pulls have been removed.") + + @adpem.command(aliases=['option']) + @opted_in(False) + async def optin(self, ctx): + """Opt in to the data collection system""" + if not await get_user_confirmation(ctx, f"Thanks for participating in our data collection! We take selection" + f" bias VERY seriously." + f" (See: )" + f" By participating, you are agreeing to report **every single pull" + f" that you make, every single day**. If you only report SOME pulls," + f" then you are likely to forget your worse pulls and report only the" + f" better pulls, which would bias our statistics towards the better" + f" pulls and make us sad. {emoji_cache.get_by_name('blobsad')}" + f"\n\nSo report all pulls and give us nice," + f" unbiased data! {emoji_cache.get_by_name('blobcheer')}" + f"\n\nDo you agree?", + force_delete=False, show_feedback=True, timeout=30): + return + await self.config.user(ctx.author).opted_in.set(True) + + @adpem.command() + @opted_in(True) + async def setaccounts(self, ctx, number_of_accounts: int): + """Set how many accounts you'll be using with this cog""" + if not (1 <= number_of_accounts <= 10): + await ctx.send("You must have at most 10 accounts.") + await self.config.user(ctx.author).accounts.set(number_of_accounts) + await ctx.tick() + + @report.command() + @opted_in(True) + async def toggleskipconfirm(self, ctx): + """Toggle if you want to confirm on skip/forgot""" + await self.config.user(ctx.author).skipconf.set(not await self.config.user(ctx.author).skipconf()) + await ctx.send(f"You have {'dis' if await self.config.user(ctx.author).skipconf() else 'en'}abled confirmation" + f" for `skip` and `forgot`.") + + @reportyesterday.command(name="toggleskipconfirm") + @opted_in(True) + async def y_toggleskipconfirm(self, ctx): + """Toggle if you want to confirm on skip/forgot""" + await self.toggleskipconfirm(ctx) + + @adpem.group(name="config") + @commands.is_owner() + async def v_config(self, ctx): + """Admin config""" + pass + + @v_config.command() + async def maxrolls(self, ctx, *lens: int): + """Change the valid number of pulls. + + If inputting multiple, separate with spaces + """ + await self.config.valid_lengths.set(lens) + await ctx.tick() + + @adpem.command() + async def showstats(self, ctx, *, query): + dbcog: Any = ctx.bot.get_cog("DBCog") + + original_author_id = ctx.message.author.id + + if dbcog is None: + return await ctx.send("Required cogs not loaded. Please alert a bot owner.") + await dbcog.wait_until_ready() + + async with ctx.typing(): + query = '" "'.join(query.split()) + monsters, _ = await dbcog.find_monsters(f'inadpem "{query}"') + if not monsters: + return await ctx.send("No monsters matched that query.") + + pulls = await self.config.pulls() + data = [(uid, ps, ts) for uid, ps, ts in pulls if not isinstance(ps, str) and ts > LAST_RESET] + total = [dbcog.get_monster(p) for uid, ps, ts in data for p in ps] + adj = [dbcog.get_monster(p) for uid, ps, ts in data if self.has_good_data(uid, pulls) for p in ps] + you = [dbcog.get_monster(p) for uid, ps, ts in data if uid == ctx.author.id for p in ps] + valid = {m for m in total if m in monsters} + + if not valid: + return await ctx.send("No monsters matching that query have been pulled.") + + most_common = Counter(m for m in total if m in monsters).most_common(1)[0][0] + menu = ClosableEmbedMenu.menu() + props = ShowStatsViewProps(total, adj, you, valid, most_common) + query_settings = await QuerySettings.extract_raw(ctx.author, self.bot, query) + state = ClosableEmbedViewState(original_author_id, ClosableEmbedMenu.MENU_TYPE, query.replace('"', ''), + query_settings, ShowStatsView.VIEW_TYPE, props) + + await menu.create(ctx, state) + + async def assert_ready(self, ctx, midnight: float) -> bool: + pulls = await self.config.pulls() + numtoday = len([1 for uid, v, ts in pulls + if midnight <= ts < midnight + (24 * 60 * 60) and uid == ctx.author.id]) + if numtoday >= await self.config.user(ctx.author).accounts(): + await ctx.send(f"You already reported {numtoday} times" + f" {'today' if midnight > time.time() - 24 * 60 * 60 else 'yesterday'}, which " + f"is your current number of accounts for reporting. If you're pulling daily on more" + f" accounts than this, you may increase your number of accounts via" + f" `{ctx.prefix}{ctx.invoked_parents[0]} setaccounts`, however please keep in" + f" mind we're very worried about selection bias, so please only do this if you" + f" are actually going to report daily!") + return False + return True + + async def add_pull(self, ctx, values: Any, at_time: float): + async with self.config.pulls() as pulls: + pulls.append((ctx.author.id, values, at_time)) + + def has_good_data(self, uid: int, pulls): + dbcog: Any = self.bot.get_cog("DBCog") + + udata = [d for d in pulls if d[0] == uid] + if len([1 for uid, ps, t in udata if ps == "Forgot"]) / len(udata) > .3: + return False + if len(udata) <= 2: + return False + + # upulls = [dbcog.get_monster(p) for u, ps, t in udata if isinstance(ps, list) for p in ps] + # if len([m for m in upulls if dbcog.database.graph.monster_is_rem_evo(m)]) / len(upulls) > .5: + # return False + + return True + + @staticmethod + def midnight() -> float: + return get_last_time(NEW_DAY, NA_TIMEZONE).astimezone(timezone.utc).timestamp() diff --git a/crowddata/mixins/adpem/menu/closable_embed.py b/crowddata/mixins/adpem/menu/closable_embed.py new file mode 100644 index 00000000..cb654734 --- /dev/null +++ b/crowddata/mixins/adpem/menu/closable_embed.py @@ -0,0 +1,9 @@ +from tsutils.menu.closable_embed_base import ClosableEmbedMenuBase + +from crowddata.mixins.adpem.view.show_stats import ShowStatsView + + +class ClosableEmbedMenu(ClosableEmbedMenuBase): + view_types = { + ShowStatsView.VIEW_TYPE: ShowStatsView + } diff --git a/crowddata/mixins/adpem/view/__init__.py b/crowddata/mixins/adpem/view/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/crowddata/mixins/adpem/view/show_stats.py b/crowddata/mixins/adpem/view/show_stats.py new file mode 100644 index 00000000..69525152 --- /dev/null +++ b/crowddata/mixins/adpem/view/show_stats.py @@ -0,0 +1,72 @@ +from collections import Counter +from typing import Collection, TYPE_CHECKING + +from discordmenu.embed.base import Box +from discordmenu.embed.components import EmbedMain, EmbedThumbnail, EmbedField +from discordmenu.embed.text import LabeledText +from discordmenu.embed.view import EmbedView +from tsutils.menu.components.footers import embed_footer_with_state +from tsutils.menu.view.closable_embed import ClosableEmbedViewState +from tsutils.tsubaki.links import MonsterImage + +if TYPE_CHECKING: + from dbcog.models.monster_model import MonsterModel + +ORDINAL_WORDS = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth'] + + +class ShowStatsViewProps: + def __init__(self, total: Collection["MonsterModel"], adj: Collection["MonsterModel"], + you: Collection["MonsterModel"], valid: Collection["MonsterModel"], + most_common: "MonsterModel"): + self.valid = valid + self.total = total + self.adj = adj + self.you = you + self.most_commmon = most_common + + +class ShowStatsView: + VIEW_TYPE = 'ShowStats' + + MAX_EXPANDED_RESULTS = 6 + + @staticmethod + def get_count(arr: Collection["MonsterModel"], *elements: "MonsterModel") -> str: + if not arr: + return "N/A" + count = sum(1 for m in arr if m in elements) + if count == 0: + return "0" + return f"{round(100*count/len(arr), 3)}%" + + @classmethod + def embed(cls, state: ClosableEmbedViewState, props: ShowStatsViewProps): + fields = [] + for mon, c in Counter(props.valid).most_common( + cls.MAX_EXPANDED_RESULTS if len(props.valid) == cls.MAX_EXPANDED_RESULTS + else cls.MAX_EXPANDED_RESULTS-1): + fields.append(EmbedField(mon.name_en, Box( + LabeledText("Net", ShowStatsView.get_count(props.total, mon)), + LabeledText("Adj", ShowStatsView.get_count(props.adj, mon)), + LabeledText("You", ShowStatsView.get_count(props.you, mon)) + ), inline=True)) + if len(props.valid) > cls.MAX_EXPANDED_RESULTS: + fields.append(EmbedField("... & More", + Box(f"+ {len(props.valid)-cls.MAX_EXPANDED_RESULTS-1} more monsters"), + inline=True)) + + return EmbedView( + EmbedMain( + color=state.query_settings.embedcolor, + title=f"AdPEM Data for query: {state.raw_query}", + description=Box( + LabeledText("Net", ShowStatsView.get_count(props.total, *props.valid)), + LabeledText("Adj", ShowStatsView.get_count(props.adj, *props.valid)), + LabeledText("You", ShowStatsView.get_count(props.you, *props.valid)) + ), + ), + embed_thumbnail=EmbedThumbnail(MonsterImage.icon(props.most_commmon.monster_id)), + embed_fields=fields, + embed_footer=embed_footer_with_state(state) + ) diff --git a/crud/__init__.py b/crud/__init__.py new file mode 100644 index 00000000..267055e3 --- /dev/null +++ b/crud/__init__.py @@ -0,0 +1,7 @@ +from .crud import Crud + +__red_end_user_data_statement__ = "User email addresses are stored persistently." + + +async def setup(bot): + bot.add_cog(Crud(bot)) if not __import__('asyncio').iscoroutinefunction(bot.add_cog) else await bot.add_cog(Crud(bot)) diff --git a/crud/crud.py b/crud/crud.py new file mode 100644 index 00000000..7397f16a --- /dev/null +++ b/crud/crud.py @@ -0,0 +1,545 @@ +import json +import logging +import os +import re +from datetime import datetime +from io import BytesIO +from typing import Any, TYPE_CHECKING + +import aiofiles +import aiomysql +import discord +import pygit2 +from redbot.core import Config, checks, commands, errors +from redbot.core.utils.chat_formatting import box, inline, pagify +from tsutils.cogs.globaladmin import auth_check +from tsutils.tsubaki.monster_header import MonsterHeader +from tsutils.user_interaction import get_user_confirmation, send_cancellation_message + +from crud.editseries import EditSeries + +if TYPE_CHECKING: + from dbcog import DBCog + +logger = logging.getLogger('red.padbot-cogs.crud') + +SERIES_KEYS = { + "name_en": 'Untranslated', + "name_ja": 'Untranslated', + "name_ko": 'Untranslated', + "series_type": None, +} + +AWOKEN_SKILL_KEYS = { + "awoken_skill_id": -1, + "name_en": 'Untranslated', + "name_ja": 'Untranslated', + "name_ko": 'Untranslated', + "desc_en": 'Untranslated', + "desc_ja": 'Untranslated', + "desc_ko": 'Untranslated', +} + +SERIES_TYPES = [ + "regular", + "event", + "seasonal", + "ghcollab", + "collab", + "lowpriority", +] + + +async def check_crud_channel(ctx): + chan = await ctx.bot.get_cog("Crud").config.chan() + return chan is None or chan == ctx.channel.id or ctx.author.id in ctx.bot.owner_ids + + +class Crud(commands.Cog, EditSeries): + """PadGuide CRUD""" + + json_folder = 'etl/pad/storage_processor/' + + def __init__(self, bot, *args, **kwargs): + super().__init__(*args, **kwargs) + self.bot = bot + self.config = Config.get_conf(self, identifier=3270) + self.config.register_global(config_file=None, pipeline_base=None, chan=None) + self.config.register_user(email=None) + + GADMIN_COG: Any = self.bot.get_cog("GlobalAdmin") + if GADMIN_COG: + GADMIN_COG.register_perm("crud") + else: + raise errors.CogLoadError("Global Administration cog must be loaded. Make sure it's " + "installed from core-cogs and load it via `^load globaladmin`") + + async def red_get_data_for_user(self, *, user_id): + """Get a user's personal data.""" + email = self.config.user_from_id(user_id) + if email: + data = f"You have a stored email ({email})" + else: + data = "No data is stored for user with ID {}.\n".format(user_id) + return {"user_data.txt": BytesIO(data.encode())} + + async def red_delete_data_for_user(self, *, requester, user_id): + """Delete a user's personal data.""" + await self.config.user_from_id(user_id).clear() + + async def get_cursor(self): + async with aiofiles.open(await self.config.config_file(), 'r') as db_config: + return (await self.connect(json.loads(await db_config.read()))).cursor() + + async def get_dbcog(self) -> "DBCog": + dbcog: Any = self.bot.get_cog("DBCog") + if dbcog is None: + raise ValueError("DBCog cog is not loaded") + await dbcog.wait_until_ready() + return dbcog + + def connect(self, db_config): + return aiomysql.connect(host=db_config['host'], + user=db_config['user'], + password=db_config['password'], + db=db_config['db'], + charset=db_config['charset'], + cursorclass=aiomysql.cursors.DictCursor, + autocommit=True) + + async def execute_write(self, ctx, sql, replacements): + async with await self.get_cursor() as cursor: + affected = await cursor.execute(sql, replacements) + await ctx.send(inline(sql % replacements)) + await ctx.send("{} row(s) affected.".format(affected)) + + async def execute_read(self, ctx, sql, replacements): + async with await self.get_cursor() as cursor: + await cursor.execute(sql, replacements) + results = list(await cursor.fetchall()) + msg = 'Results\n' + json.dumps(results, indent=2, ensure_ascii=False, sort_keys=True) + await ctx.send(inline(sql % replacements)) + for page in pagify(msg): + await ctx.send(box(page)) + + async def git_verify(self, ctx, folder, filename): + filepath = folder + filename + + try: + repo = pygit2.Repository(await self.config.pipeline_base()) + except pygit2.GitError: + return + if not repo.diff(): + return + if not repo.lookup_branch("master").is_checked_out(): + await send_cancellation_message(ctx, f"Hey {ctx.author.mention}, the pipeline branch is currently **not**" + f" set to master! Please inform a sysadmin that this crud change" + f" was only **temporarily** made!") + return + if any(diff for diff in repo.diff().deltas if diff.old_file.path != filepath): + return await send_cancellation_message(ctx, f"Hey {ctx.author.mention}, there are currently staged changes." + f" Please inform a sysadmin so that your changes can be" + f" manually committed.") + + keys = await self.bot.get_shared_api_tokens("github") + if "username" not in keys or "token" not in keys: + return await send_cancellation_message(ctx, f"Github credentials unset. Add via `{ctx.prefix}set api" + f" github username token `") + + try: + index = repo.index + index.add(filepath) + index.write() + tree = index.write_tree() + email = await self.config.user(ctx.author).email() + author = pygit2.Signature(re.sub(r'[<>]', '', str(ctx.author)), + email or "famiel@tsubakibot.com") + commiter = pygit2.Signature("Famiel", "famiel@tsubakibot.com") + parent, ref = repo.resolve_refish(refish=repo.head.name) + repo.create_commit(ref.name, author, commiter, f"Updating {filename}", tree, [parent.oid]) + upcred = pygit2.UserPass(keys['username'], keys['token']) + remote = discord.utils.get(repo.remotes, name="origin") + remote.push(['refs/heads/master'], callbacks=pygit2.RemoteCallbacks(credentials=upcred)) + except Exception as e: + logger.exception("Failed to push.") + await send_cancellation_message(ctx, f"Failed to push to github. Please alert a sysadmin.") + + @commands.group() + @auth_check('crud') + @commands.check(check_crud_channel) + async def crud(self, ctx): + """PadGuide database CRUD.""" + + @commands.command() + async def editmonsname(self, ctx, monster_id: int, *, name): + """Change a monster's name_en_override + + To remove a name override, set the name to None, NULL, or "" + """ + if name.startswith('"') and name.endswith('"') and name.count('"') <= 2: + name = name[1:-1] + + if name.lower() in ["none", "null", ""]: + name = None + + async with await self.get_cursor() as cursor: + # Get the most recent english translation override + await cursor.execute("""SELECT + monsters.name_en AS monster_name, + monster_name_overrides.name_en AS old_override + FROM monster_name_overrides + RIGHT JOIN monsters + ON monsters.monster_id = monster_name_overrides.monster_id + WHERE monsters.monster_id = %s AND (is_translation = 1 OR is_translation IS NULL) + ORDER BY monster_name_overrides.tstamp DESC LIMIT 1""", (monster_id,)) + old_val = await cursor.fetchall() + + if not old_val: + return await ctx.send(f"There is no monster with id {monster_id}") + monster_name = old_val[0]['monster_name'] + old_name = old_val[0]['old_override'] + if not await get_user_confirmation(ctx, (f"Are you sure you want to change `{monster_name} ({monster_id})`'s" + f" english override from `{old_name}` to `{name}`?")): + return + + sql = (f'INSERT INTO monster_name_overrides (monster_id, name_en, is_translation, tstamp)' + f' VALUES (%s, %s, 1, UNIX_TIMESTAMP())') + await self.execute_write(ctx, sql, (monster_id, name)) + + @crud.command() + async def searchdungeon(self, ctx, *, search_text): + """Search for a dungeon via its jp or na name""" + search_text = '%{}%'.format(search_text).lower() + sql = ('SELECT dungeon_id, name_en, name_ja, visible FROM dungeons' + ' WHERE lower(name_en) LIKE %s OR lower(name_ja) LIKE %s' + ' ORDER BY dungeon_id DESC LIMIT 20') + await self.execute_read(ctx, sql, (search_text, search_text)) + + @crud.group() + async def series(self, ctx): + """Series related commands""" + + @series.command(name="search") + async def series_search(self, ctx, *, search_text): + """Search for a series via its jp or na name""" + search_text = '%{}%'.format(search_text).lower() + sql = ('SELECT series_id, name_en, name_ja, name_ko FROM series' + ' WHERE lower(name_en) LIKE %s OR lower(name_ja) LIKE %s' + ' ORDER BY series_id DESC LIMIT 20') + await self.execute_read(ctx, sql, (search_text, search_text)) + + @series.command(name="add") + async def series_add(self, ctx, *elements): + """Add a new series. + + Valid element keys are: `name_en`, `name_ko`, `name_ja`, `series_type` + + Example Usage: + [p]crud series add key1 "Value1" key2 "Value2" + """ + if not elements: + return await ctx.send_help() + if len(elements) % 2 != 0: + return await send_cancellation_message(ctx, "Imbalanced key-value pairs. Make sure" + " multi-word values are in quotes") + elements = {elements[i]: elements[i + 1] for i in range(0, len(elements), 2)} + + if not (elements and all(x in SERIES_KEYS for x in elements)): + return await send_cancellation_message(ctx, f"Valid keys are {', '.join(map(inline, SERIES_KEYS))}") + + if "series_type" in elements and elements['series_type'] not in SERIES_TYPES: + return await send_cancellation_message(ctx, "`series_type` must be one of: " + ", ".join(SERIES_TYPES)) + + EXTRAS = {} + async with await self.get_cursor() as cursor: + await cursor.execute("SELECT MAX(series_id) AS max_id FROM series") + max_val = (await cursor.fetchall())[0]['max_id'] + EXTRAS['series_id'] = max_val + 1 + EXTRAS['tstamp'] = int(datetime.now().timestamp()) + elements = {**SERIES_KEYS, **EXTRAS, **elements} + + key_infix = ", ".join(elements.keys()) + value_infix = ", ".join("%s" for v in elements.values()) + sql = ('INSERT INTO series ({})' + ' VALUES ({})').format(key_infix, value_infix) + + await self.execute_write(ctx, sql, (*elements.values(),)) + + fn = os.path.join(await self.config.pipeline_base(), self.json_folder, 'series.json') + async with aiofiles.open(fn, 'r') as f: + j = json.loads(await f.read()) + j.append({ + 'name_ja': elements['name_ja'], + 'name_en': elements['name_en'], + 'name_ko': elements['name_ko'], + 'series_id': elements['series_id'], + 'series_type': elements['series_type'] + }) + async with aiofiles.open(fn, 'w') as f: + await f.write(json.dumps(j, indent=2, ensure_ascii=False, sort_keys=True)) + await self.git_verify(ctx, self.json_folder, 'series.json') + + @series.command(name="edit") + async def series_edit(self, ctx, series_id: int, *elements): + """Edit an existing series. + + Valid element keys are: `name_en`, `name_ko`, `name_ja`, `series_type` + + Example Usage: + [p]crud series edit 100 key1 "Value1" key2 "Value2" + """ + if not elements: + return await ctx.send_help() + if len(elements) % 2 != 0: + return await send_cancellation_message(ctx, "Imbalanced key-value pairs. Make sure" + " multi-word values are in quotes") + elements = {elements[i]: elements[i + 1] for i in range(0, len(elements), 2)} + + if not (elements and all(x in SERIES_KEYS for x in elements)): + return await ctx.send_help() + + if "series_type" in elements and elements['series_type'] not in SERIES_TYPES: + return await ctx.send("`series_type` must be one of: " + ", ".join(SERIES_TYPES)) + + replacement_infix = ", ".join(["{} = %s".format(k) for k in elements.keys()]) + sql = ('UPDATE series' + ' SET {}' + ' WHERE series_id = %s').format(replacement_infix) + + await self.execute_write(ctx, sql, (*elements.values(), series_id)) + + fn = os.path.join(await self.config.pipeline_base(), self.json_folder, 'series.json') + async with aiofiles.open(fn, 'r') as f: + j = json.loads(await f.read()) + for e in j: + if e['series_id'] == series_id: + e.update(elements) + async with aiofiles.open(fn, 'w') as f: + await f.write(json.dumps(j, indent=2, ensure_ascii=False, sort_keys=True)) + await self.git_verify(ctx, self.json_folder, 'series.json') + + @series.command(name="delete") + @checks.is_owner() + async def series_delete(self, ctx, series_id: int): + """Delete an existing series""" + sql = ('DELETE FROM series' + ' WHERE series_id = %s') + + await self.execute_write(ctx, sql, series_id) + + fn = os.path.join(await self.config.pipeline_base(), self.json_folder, 'series.json') + async with aiofiles.open(fn, 'r') as f: + j = json.loads(await f.read()) + for e in j[:]: + if e['series_id'] == series_id: + j.remove(e) + async with aiofiles.open(fn, 'w') as f: + await f.write(json.dumps(j, indent=2, ensure_ascii=False, sort_keys=True)) + await self.git_verify(ctx, self.json_folder, 'series.json') + + @crud.group(aliases=["awos"]) + async def awokenskill(self, ctx): + """Awoken skill related commands""" + + @awokenskill.command(name="search") + async def awokenskill_search(self, ctx, *, search_text): + """Search for a awoken skill via its jp or na name""" + dbcog = await self.get_dbcog() + if search_text in dbcog.KNOWN_AWOKEN_SKILL_TOKENS: + where = f'awoken_skill_id = %s' + replacements = (dbcog.KNOWN_AWOKEN_SKILL_TOKENS[search_text].value,) + else: + where = f"lower(name_en) LIKE %s OR lower(name_ja) LIKE %s" + replacements = ('%{}%'.format(search_text).lower(),) * 2 + sql = (f'SELECT awoken_skill_id, name_en, name_ja, name_ko, desc_en,' + f' desc_ja, desc_ko FROM awoken_skills' + f' WHERE {where}' + f' ORDER BY awoken_skill_id DESC LIMIT 20') + await self.execute_read(ctx, sql, replacements) + + @awokenskill.command(name="add") + @checks.is_owner() + async def awokenskill_add(self, ctx, *elements): + """Add a new awoken skill. + + Valid element keys are: `name_en`, `name_ko`, `name_ja`, `desc_en`, `desc_ko`, `desc_ja` + + Example Usage: + [p]crud awokenskill add key1 "Value1" key2 "Value2" + """ + if not elements: + return await ctx.send_help() + if len(elements) % 2 != 0: + return await send_cancellation_message(ctx, "Imbalanced key-value pairs. Make sure" + " multi-word values are in quotes") + elements = {elements[i]: elements[i + 1] for i in range(0, len(elements), 2)} + + if not (elements and all(x in AWOKEN_SKILL_KEYS for x in elements)): + return await ctx.send_help() + + if "awoken_skill_id" not in elements or not elements["awoken_skill_id"].isdigit(): + return await ctx.send("You must supply an numeric `awoken_skill_id` when adding a new awoken skill.") + elements["awoken_skill_id"] = int(elements["awoken_skill_id"]) + + EXTRAS = { + 'adj_hp': 0, + 'adj_atk': 0, + 'adj_rcv': 0, + 'tstamp': int(datetime.now().timestamp()) + } + elements = {**AWOKEN_SKILL_KEYS, **EXTRAS, **elements} + + key_infix = ", ".join(elements.keys()) + value_infix = ", ".join("%s" for v in elements.values()) + sql = ('INSERT INTO awoken_skills ({})' + ' VALUES ({})').format(key_infix, value_infix) + + await self.execute_write(ctx, sql, (*elements.values(),)) + + fn = os.path.join(await self.config.pipeline_base(), self.json_folder, 'awoken_skill.json') + async with aiofiles.open(fn, 'r') as f: + j = json.loads(await f.read()) + j.append({ + "adj_atk": 0, + "adj_hp": 0, + "adj_rcv": 0, + "desc_ja": elements['desc_ja'], + "desc_ja_official": "Unknown Official Text", + "desc_ko": elements['desc_ko'], + "desc_ko_official": "Unknown Official Text", + "desc_en": elements['desc_en'], + "desc_en_official": "Unknown Official Text", + 'name_ja': elements['name_ja'], + "name_ja_official": "Unknown Official Name", + 'name_en': elements['name_en'], + "name_ko_official": "Unknown Official Name", + 'name_ko': elements['name_ko'], + "name_en_official": "Unknown Official Name", + "pad_awakening_id": elements['awoken_skill_id'], + }) + async with aiofiles.open(fn, 'w') as f: + await f.write(json.dumps(j, indent=2, ensure_ascii=False, sort_keys=True)) + await self.git_verify(ctx, self.json_folder, 'awoken_skill.json') + + @awokenskill.command(name="edit") + async def awokenskill_edit(self, ctx, awoken_skill, *elements): + """Edit an existing awoken skill. + + Valid element keys are: `name_en`, `name_ko`, `name_ja`, `desc_en`, `desc_ko`, `desc_ja` + + Example Usage: + [p]crud awokenskill edit 100 key1 "Value1" key2 "Value2" + [p]crud awokenskill edit misc_comboboost key1 "Value1" key2 "Value2" + """ + dbcog = await self.get_dbcog() + if awoken_skill in dbcog.KNOWN_AWOKEN_SKILL_TOKENS: + awoken_skill_id = dbcog.KNOWN_AWOKEN_SKILL_TOKENS[awoken_skill].value + elif awoken_skill.isdigit(): + awoken_skill_id = int(awoken_skill) + else: + return await ctx.send("Invalid awoken skill.") + + awoken_skill_id = int(awoken_skill_id) + + if not elements: + return await ctx.send_help() + if len(elements) % 2 != 0: + return await send_cancellation_message(ctx, "Imbalanced key-value pairs. Make sure" + " multi-word values are in quotes") + elements = {elements[i]: elements[i + 1] for i in range(0, len(elements), 2)} + + if not (elements and all(x in AWOKEN_SKILL_KEYS for x in elements)): + return await ctx.send_help() + + if 'awoken_skill_id' in elements: + return await ctx.send("`awoken_skill_id` is not a supported key for editing.") + + replacement_infix = ", ".join(["{} = %s".format(k) for k in elements.keys()]) + sql = ('UPDATE awoken_skills' + ' SET {}' + ' WHERE awoken_skill_id = %s').format(replacement_infix) + + await self.execute_write(ctx, sql, (*elements.values(), awoken_skill_id)) + + fn = os.path.join(await self.config.pipeline_base(), self.json_folder, 'awoken_skill.json') + async with aiofiles.open(fn, 'r') as f: + j = json.loads(await f.read()) + + for e in j: + if e['pad_awakening_id'] == awoken_skill_id: + e.update(elements) + async with aiofiles.open(fn, 'w') as f: + await f.write(json.dumps(j, indent=2, ensure_ascii=False, sort_keys=True)) + await self.git_verify(ctx, self.json_folder, 'awoken_skill.json') + + @awokenskill.command(name="delete") + @checks.is_owner() + async def awokenskill_delete(self, ctx, awoken_skill_id: int): + """Delete an existing awoken skill""" + sql = ('DELETE FROM awoken_skills' + ' WHERE awoken_skill_id = %s') + + await self.execute_write(ctx, sql, awoken_skill_id) + + fn = os.path.join(await self.config.pipeline_base(), self.json_folder, 'awoken_skill.json') + async with aiofiles.open(fn, 'r') as f: + j = json.loads(await f.read()) + for e in j[:]: + if e['pad_awakening_id'] == awoken_skill_id: + j.remove(e) + async with aiofiles.open(fn, 'w') as f: + await f.write(json.dumps(j, indent=2, ensure_ascii=False, sort_keys=True)) + await self.git_verify(ctx, self.json_folder, 'awoken_skill.json') + + @crud.command() + @checks.is_owner() + async def setconfig(self, ctx, path): + await self.config.config_file.set(path) + await ctx.tick() + + @crud.command() + @checks.is_owner() + async def pipeline_base(self, ctx, path): + await self.config.pipeline_base.set(path) + await ctx.tick() + + @crud.command() + @checks.is_owner() + async def setchan(self, ctx, channel: discord.TextChannel): + await self.config.chan.set(channel.id) + await ctx.tick() + + @crud.command() + @checks.is_owner() + async def rmchan(self, ctx): + await self.config.chan.set(None) + await ctx.tick() + + @commands.command(aliases=['group#', 'ghgroup#']) + async def ghgroupn(self, ctx, *, query): + dbcog: Any = self.bot.get_cog("DBCog") + if dbcog is None: + return await ctx.send("DBCog not loaded.") + monster = await dbcog.find_monster(query, ctx.author.id) + if monster is None: + return await ctx.send("No matching monster found.") + await ctx.send(f"{MonsterHeader.text_with_emoji(monster)} is in GungHo Group #{monster.group_id}") + + @commands.command(aliases=['collab#', 'ghcollab#']) + async def ghcollabn(self, ctx, *, query): + dbcog: Any = self.bot.get_cog("DBCog") + if dbcog is None: + return await ctx.send("DBCog not loaded.") + monster = await dbcog.find_monster(query, ctx.author.id) + if monster is None: + return await ctx.send("No matching monster found.") + await ctx.send(f"{MonsterHeader.text_with_emoji(monster)} is in GungHo Collab #{monster.collab_id}") + + @crud.command() + async def setmyemail(self, ctx, email=None): + """Sets your email so GitHub commits can be properly attributed""" + if email is not None and \ + not re.match(r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)", email): + return await ctx.send("Invalid email.") + await self.config.user(ctx.author).email.set(email) + await ctx.tick() diff --git a/crud/editseries.py b/crud/editseries.py new file mode 100644 index 00000000..21f19b2f --- /dev/null +++ b/crud/editseries.py @@ -0,0 +1,323 @@ +import logging +import re +from collections import defaultdict +from typing import Callable, Coroutine, Iterable, List, Literal, Sequence, TYPE_CHECKING, Tuple, TypeVar + +from aiomysql import Cursor +from discord.ext.commands import BadArgument, Converter +from redbot.core import Config +from redbot.core.bot import Red +from redbot.core.commands import commands +from tsutils.enums import Server +from tsutils.errors import ClientInlineTextException +from tsutils.tsubaki.monster_header import MonsterHeader +from tsutils.user_interaction import get_user_confirmation + +if TYPE_CHECKING: + from dbcog import DBCog + from dbcog.models.monster_model import MonsterModel + +logger = logging.getLogger('red.padbot-cogs.crud.editseries') +T = TypeVar("T") +CR = '\n' + +if not TYPE_CHECKING: + class AliasedKeywordConverter(Converter): + def __init__(self, aliases): + self.aliases = aliases + + async def convert(self, ctx, argument) -> str: + for kw, aliases in self.aliases.items(): + if argument == kw or (aliases is not None and argument in aliases): + return kw + raise BadArgument(f"Bad keyword `{argument}`." + f" Valid keywords are `{'`, `'.join(self.aliases)}`.") + + + EditSeriesCommand = AliasedKeywordConverter({ + 'add': None, + 'remove': ('rm', 'delete', 'del'), + 'promote': None, + }) + EditSeriesTarget = AliasedKeywordConverter({ + 'monster': ('monsters'), + 'tree': ('trees'), + 'group': ('groups'), + 'collab': ('collabs'), + }) + PropagateTarget = AliasedKeywordConverter({ + 'series': None, + 'group': ('groups'), + 'collab': ('collabs'), + }) +else: + EditSeriesCommand = Literal['add', 'remove', 'promote'] + EditSeriesTarget = Literal['monster', 'tree', 'group', 'collab'] + PropagateTarget = Literal['series', 'group', 'collab'] + + +def disjoint_sets(superset: Iterable["MonsterModel"], f: Callable[["MonsterModel"], bool]) \ + -> Tuple[List["MonsterModel"], List["MonsterModel"]]: + a, b = set(), set() + for e in superset: + (a if f(e) else b).add(e) + return sorted(a, key=lambda m: m.monster_id), sorted(b, key=lambda m: m.monster_id) + + +class EditSeries: + config: Config + bot: Red + get_cursor: Callable[[], Coroutine[None, None, Cursor]] + get_dbcog: Callable[[], Coroutine[None, None, "DBCog"]] + + async def es_execute_write(self, ctx, sql: str, replacements: Sequence = ()) -> int: + async with await self.get_cursor() as cursor: + affected = 0 + for mon in re.split(r'\n\s*\n', sql): + changed = 0 + for query in mon.split(';'): + if query.strip(): + changed += await cursor.execute(query, replacements) + affected += bool(changed) + display = re.sub(r'\n[ \t]+', r'\n', sql % replacements) + display = re.sub(r'\n', r'\n\t', display).strip() + logger.info(f"{ctx.author} executed the following query via {ctx.message.content}:" + f"\n{display}" + f"\nwhich affected {affected} monster(s).") + await ctx.send("{} monster(s) affected.".format(affected)) + return affected + + @commands.group(aliases=['seriesedit'], invoke_without_command=True) + async def editseries(self, ctx, command: EditSeriesCommand, series_id: int, + target: EditSeriesTarget, *, target_str): + """Affect the series of a target. + + Commands: + `add` - Add a series. If this is the only series, add as a primary series + `remove` - Remove a series. If this is the primary series, randomly choose another to take its place + `promote` - Add a series as primary, possibly demoting an existing primary in the process + + Targets: + `monster` - Monsters specified by id + `tree` - Monster trees specified by monster ids of any monster in the tree + `group` - GH groups specified by group id + `collab` - GH collabs specified by collab id + + Examples: + [p]editseries add 91 monster 1319, 1825 + [p]editseries rm 7 tree 1 + [p]editseries promote 28 group 112 + """ + + async def tryint(arg): + if not arg.isnumeric(): + await ctx.send_help() + raise ClientInlineTextException() + + dbcog = await self.get_dbcog() + + monsters = None + if target == 'monster': + monsters = [dbcog.get_monster(int(mid)) + for mid in re.split(r'\D+', target_str)] + elif target == 'tree': + monsters = [monster + for bmid in re.split(r'\D+', target_str) + for monster in dbcog.database.graph.get_alt_monsters(dbcog.get_monster(int(bmid)))] + elif target == 'group': + monsters = [monster + for gid in re.split(r'\D+', target_str) + for monster in dbcog.database.get_monsters_where(lambda m: m.group_id == int(gid), + server=Server.COMBINED)] + elif target == 'collab': + monsters = [monster + for gid in re.split(r'\D+', target_str) + for monster in dbcog.database.get_monsters_where(lambda m: m.collab_id == int(gid), + server=Server.COMBINED)] + if not monsters: + return await ctx.send("No matching monsters found.") + + if command == 'add': + await self.es_add(ctx, series_id, monsters) + elif command == 'remove': + await self.es_remove(ctx, series_id, monsters) + elif command == 'promote': + await self.es_promote(ctx, series_id, monsters) + + @editseries.command() + async def propagate(self, ctx, target: PropagateTarget, *, target_ids): + target_ids = [int(d) for d in re.split(r'\D+', target_ids)] + dbcog = await self.get_dbcog() + + monsters = None + if target == "series": + if target_ids == 0: + return await ctx.send("You cannot propagate the unsorted series.") + monsters = {evo + for monster in dbcog.database.get_monsters_where(lambda m: m.series_id in target_ids, + server=Server.COMBINED) + for evo in dbcog.database.graph.get_alt_monsters(monster)} + elif target == "group": + monsters = {monster for monster in dbcog.database.get_monsters_where(lambda m: m.group_id in target_ids, + server=Server.COMBINED)} + elif target == "collab": + monsters = {monster for monster in dbcog.database.get_monsters_where(lambda m: m.collab_id in target_ids, + server=Server.COMBINED)} + + if not monsters: + return await ctx.send("No monsters found.") + + series = {m.series_id for m in monsters}.difference({0}) + if len(series) != 1: + return await ctx.send("The requested series has inconsistant trees.") + + await self.es_promote(ctx, next(iter(series)), sorted(monsters, key=lambda m: m.monster_id)) + + async def es_add(self, ctx, series_id: int, monsters: List["MonsterModel"]): + monsters_str = "(" + ",".join(str(m.monster_id) for m in monsters) + ")" + async with await self.get_cursor() as cursor: + await cursor.execute(f""" + SELECT + monster_id, series_id + FROM monster_series + WHERE monster_id IN {monsters_str}""", ()) + rows = await cursor.fetchall() + sids = defaultdict(set) + for row in rows: + sids[row['monster_id']].add(row['series_id']) + secondary, primary = disjoint_sets(monsters, lambda m: m.monster_id in sids) + seen, secondary = disjoint_sets(secondary, lambda m: series_id in sids[m.monster_id]) + await cursor.execute("SELECT name_en FROM series WHERE series_id = %s", (series_id,)) + rows = await cursor.fetchall() + if not rows: + return await ctx.send(f"There is no series with id {series_id}") + new_series = next(iter(rows))['name_en'] + sql = "" + for monster in monsters: + if monster in seen: + continue + mid = monster.monster_id + sql += f""" + INSERT INTO monster_series + (monster_series_id, monster_id, series_id, priority, tstamp) + VALUES + ({series_id * 100000 + mid}, {mid}, {series_id}, {int(mid not in sids)}, UNIX_TIMESTAMP()); + """ + confirmation = [f"You are adding series `{new_series}`."] + if primary: + confirmation.append("The following monsters will have the series added as a primary series:\n" + + '\n'.join(map(MonsterHeader.text_with_emoji, primary))) + if secondary: + confirmation.append("The following monsters will have the series added as a secondary series:\n" + + '\n'.join(map(MonsterHeader.text_with_emoji, secondary))) + if seen: + confirmation.append("The following monsters will not be changed as they already have the series:\n" + + '\n'.join(map(MonsterHeader.text_with_emoji, seen))) + if not await get_user_confirmation(ctx, '\n\n'.join(confirmation), + timeout=30, force_delete=False): + return + await self.es_execute_write(ctx, sql) + + async def es_remove(self, ctx, series_id: int, monsters: List["MonsterModel"]): + monsters_str = "(" + ",".join(str(m.monster_id) for m in monsters) + ")" + async with await self.get_cursor() as cursor: + await cursor.execute(f""" + SELECT + monster_id, series_id, priority + FROM monster_series + WHERE monster_id IN {monsters_str}""", ()) + rows = await cursor.fetchall() + sids = defaultdict(set) + primaries = {} + for row in rows: + sids[row['monster_id']].add(row['series_id']) + if row['priority']: + primaries[row['monster_id']] = row['series_id'] + await cursor.execute("SELECT name_en FROM series WHERE series_id = %s", (series_id,)) + rows = await cursor.fetchall() + was_primary, wasnt_primary = disjoint_sets(monsters, lambda m: primaries[m.monster_id] == series_id) + unadded, wasnt_primary = disjoint_sets(wasnt_primary, lambda m: series_id not in sids[m.monster_id]) + if not rows: + return await ctx.send(f"There is no series with id {series_id}") + new_series = next(iter(rows))['name_en'] + sql = "" + for monster in monsters: + if monster in unadded: + continue + mid = monster.monster_id + sql += f""" + DELETE FROM monster_series + WHERE monster_id = {mid} AND series_id = {series_id}; + """ + if monster in was_primary: + sql += f"""UPDATE monster_series + SET priority = 1 + WHERE monster_id = {mid} + LIMIT 1; + """ + confirmation = [f"You are removing series `{new_series}`."] + if was_primary: + confirmation.append("The following monsters will have their primary series removed:\n" + + '\n'.join(map(MonsterHeader.text_with_emoji, was_primary))) + if wasnt_primary: + confirmation.append("The following monsters will have their secondary series removed:\n" + + '\n'.join(map(MonsterHeader.text_with_emoji, wasnt_primary))) + if unadded: + confirmation.append("The following monsters will continue to not have the series:\n" + + '\n'.join(map(MonsterHeader.text_with_emoji, unadded))) + if not await get_user_confirmation(ctx, '\n\n'.join(confirmation), + timeout=30, force_delete=False): + return + await self.es_execute_write(ctx, sql) + + async def es_promote(self, ctx, series_id: int, monsters: List["MonsterModel"]): + monsters_str = "(" + ",".join(str(m.monster_id) for m in monsters) + ")" + async with await self.get_cursor() as cursor: + await cursor.execute(f""" + SELECT + monster_id, series_id, priority + FROM monster_series + WHERE monster_id IN {monsters_str}""", ()) + rows = await cursor.fetchall() + await cursor.execute("SELECT name_en FROM series WHERE series_id = %s", (series_id,)) + mids = [row['monster_id'] for row in rows] + primaries = {} + for row in rows: + if row['priority']: + primaries[row['monster_id']] = row['series_id'] + has_series, no_series = disjoint_sets(monsters, lambda m: m.monster_id in mids) + already_primary, has_series = disjoint_sets(has_series, lambda m: primaries[m.monster_id] == series_id) + rows = await cursor.fetchall() + if not rows: + return await ctx.send(f"There is no series with id {series_id}") + new_series = next(iter(rows))['name_en'] + sql = "" + for monster in monsters: + if monster in already_primary: + continue + mid = monster.monster_id + sql += f""" + DELETE FROM monster_series + WHERE monster_id = {mid} AND series_id = {series_id}; + UPDATE monster_series + SET priority = 0 + WHERE monster_id = {mid}; + INSERT INTO monster_series + (monster_series_id, monster_id, series_id, priority, tstamp) + VALUES + ({series_id * 100000 + mid}, {mid}, {series_id}, 1, UNIX_TIMESTAMP()); + """ + confirmation = [f"You are promoting series `{new_series}`."] + if has_series: + confirmation.append("The following monsters will have this primary series added:\n" + + '\n'.join(map(MonsterHeader.text_with_emoji, has_series))) + if no_series: + confirmation.append("The following monsters will no longer be unsorted:\n" + + '\n'.join(map(MonsterHeader.text_with_emoji, no_series))) + if already_primary: + confirmation.append("The following monsters will not be changed:\n" + + '\n'.join(map(MonsterHeader.text_with_emoji, already_primary))) + if not await get_user_confirmation(ctx, '\n\n'.join(confirmation), + timeout=30, force_delete=False): + return + await self.es_execute_write(ctx, sql) diff --git a/crud/info.json b/crud/info.json new file mode 100644 index 00000000..93f3336f --- /dev/null +++ b/crud/info.json @@ -0,0 +1,31 @@ +{ + "author": [ + "Aradia Megido#2552" + ], + "description": "Crud for the Pipeline Database.", + "end_user_data_statement": "User email addresses are stored persistently", + "install_msg": "Use [p]crud to get started.", + "short": "Pipeline CRUD", + "min_bot_version": "3.4.0", + "max_bot_version": "0.0.0", + "min_python_version": [ + 3, + 8, + 1 + ], + "hidden": false, + "disabled": false, + "required_cogs": { + "globaladmin": "https://github.com/TsubakiBotPad/core-cogs" + }, + "requirements": [ + "tsutils>=5.0.0", + "aiomysql", + "aiofiles", + "cryptography", + "pygit2" + ], + "tags": [ + "PAD" + ] +} diff --git a/dadguide/__init__.py b/dadguide/__init__.py deleted file mode 100644 index bc15651f..00000000 --- a/dadguide/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from .dadguide import * - - -def setup(bot): - n = Dadguide(bot) - bot.add_cog(n) - n.bot.loop.create_task(n.reload_data_task()) diff --git a/dadguide/dadguide.py b/dadguide/dadguide.py deleted file mode 100644 index d607f674..00000000 --- a/dadguide/dadguide.py +++ /dev/null @@ -1,1731 +0,0 @@ -""" -Provides access to DadGuide data. - -Access via the exported DadGuide sqlite database. - -Don't hold on to any of the dastructures exported from here, or the -entire database could be leaked when the module is reloaded. -""" -import asyncio -import csv -import difflib -import json -import logging -import os -import re -import shutil -import sqlite3 as lite -import traceback -from _collections import defaultdict, deque, OrderedDict -from datetime import datetime -from enum import Enum - -import pytz -import romkan -from redbot.core import checks, data_manager -from redbot.core import commands -from redbot.core.bot import Red -from redbot.core.utils.chat_formatting import inline - -import rpadutils - -logger = logging.getLogger('red.miruv3.dadguide') - - -def _data_file(file_name: str) -> str: - return os.path.join(str(data_manager.cog_data_path(raw_name='dadguide')), file_name) - - -CSV_FILE_PATTERN = '{}.csv' -NAMES_EXPORT_PATH = _data_file('computed_names.json') -BASENAMES_EXPORT_PATH = _data_file('base_names.json') -TRANSLATEDNAMES_EXPORT_PATH = _data_file('translated_names.json') - -SHEETS_PATTERN = 'https://docs.google.com/spreadsheets/d/1EoZJ3w5xsXZ67kmarLE4vfrZSIIIAfj04HXeZVST3eY/pub?gid={}&single=true&output=csv' -GROUP_BASENAMES_OVERRIDES_SHEET = SHEETS_PATTERN.format('2070615818') -NICKNAME_OVERRIDES_SHEET = SHEETS_PATTERN.format('0') -PANTHNAME_OVERRIDES_SHEET = SHEETS_PATTERN.format('959933643') - -NICKNAME_FILE_PATTERN = _data_file(CSV_FILE_PATTERN.format('nicknames')) -BASENAME_FILE_PATTERN = _data_file(CSV_FILE_PATTERN.format('basenames')) -PANTHNAME_FILE_PATTERN = _data_file(CSV_FILE_PATTERN.format('panthnames')) - -DB_DUMP_URL = 'https://f002.backblazeb2.com/file/dadguide-data/db/dadguide.sqlite' -DB_DUMP_FILE = _data_file('dadguide.sqlite') -DB_DUMP_WORKING_FILE = _data_file('dadguide_working.sqlite') - - -class Dadguide(commands.Cog): - def __init__(self, bot: Red, *args, **kwargs): - super().__init__(*args, **kwargs) - self.bot = bot - self._is_ready = asyncio.Event() - - self.settings = DadguideSettings("dadguide") - - # A string -> int mapping, nicknames to monster_id_na - self.nickname_overrides = {} - - # An int -> set(string), monster_id_na to set of basename overrides - self.basename_overrides = defaultdict(set) - - self.panthname_overrides = defaultdict(set) - - # Map of google-translated JP names to EN names - self.translated_names = {} - - self.database = load_database(None) - self.index = None # type: MonsterIndex - - async def wait_until_ready(self): - """Wait until the Dadguide cog is ready. - - Call this from other cogs to wait until Dadguide finishes refreshing its database - for the first time. - """ - return await self._is_ready.wait() - - def create_index(self, accept_filter=None): - """Exported function that allows a client cog to create a monster index""" - return MonsterIndex(self.database, - self.nickname_overrides, - self.basename_overrides, - self.panthname_overrides, - accept_filter=accept_filter) - - def get_monster_by_id(self, monster_id: int): - """Exported function that allows a client cog to get a full DgMonster by monster_id""" - return self.database.get_monster(monster_id) - - def cog_unload(self): - # Manually nulling out database because the GC for cogs seems to be pretty shitty - if self.database: - self.database.close() - self.database = None - self._is_ready.clear() - - async def reload_data_task(self): - await self.bot.wait_until_ready() - - # We already had a copy of the database at startup, signal that we're ready now. - if self.database.has_database(): - logger.info('Using stored database at load') - self._is_ready.set() - - while self == self.bot.get_cog('Dadguide'): - short_wait = False - try: - await self.download_and_refresh_nicknames() - logger.info('Done refreshing Dadguide, triggering ready') - self._is_ready.set() - except Exception as ex: - short_wait = True - logger.info("dadguide data download/refresh failed: %s", ex) - traceback.print_exc() - - try: - wait_time = 60 if short_wait else 60 * 60 * 4 - await asyncio.sleep(wait_time) - except Exception as ex: - logger.info("dadguide data wait loop failed: %s", ex) - traceback.print_exc() - raise ex - - async def reload_config_files(self): - os.remove(NICKNAME_FILE_PATTERN) - os.remove(BASENAME_FILE_PATTERN) - os.remove(PANTHNAME_FILE_PATTERN) - await self.download_and_refresh_nicknames() - - async def download_and_refresh_nicknames(self): - if self.settings.data_file(): - logger.info('Copying dg data file') - shutil.copy2(self.settings.data_file(), DB_DUMP_FILE) - else: - logger.info('Downloading dg data files') - await self._download_files() - - logger.info('Downloading dg name override files') - await self._download_override_files() - - logger.info('Loading dg name overrides') - nickname_overrides = self._csv_to_tuples(NICKNAME_FILE_PATTERN) - basename_overrides = self._csv_to_tuples(BASENAME_FILE_PATTERN) - panthname_overrides = self._csv_to_tuples(PANTHNAME_FILE_PATTERN) - - self.nickname_overrides = {x[0].lower(): int(x[1]) - for x in nickname_overrides if x[1].isdigit()} - - self.basename_overrides = defaultdict(set) - for x in basename_overrides: - k, v = x - if k.isdigit(): - self.basename_overrides[int(k)].add(v.lower()) - - self.panthname_overrides = {x[0].lower(): x[1].lower() for x in panthname_overrides} - self.panthname_overrides.update({v: v for _, v in self.panthname_overrides.items()}) - - logger.info('Loading dg database') - self.database = load_database(self.database) - logger.info('Building dg monster index') - self.index = MonsterIndex(self.database, self.nickname_overrides, self.basename_overrides, - self.panthname_overrides) - - logger.info('Writing dg monster computed names') - self.write_monster_computed_names() - - logger.info('Done refreshing dg data') - - def write_monster_computed_names(self): - results = {} - for name, nm in self.index.all_entries.items(): - results[name] = int(rpadutils.get_pdx_id_dadguide(nm)) - - with open(NAMES_EXPORT_PATH, 'w', encoding='utf-8') as f: - json.dump(results, f) - - results = {} - for nm in self.index.all_monsters: - entry = {'bn': list(nm.group_basenames)} - if nm.extra_nicknames: - entry['nn'] = list(nm.extra_nicknames) - results[int(rpadutils.get_pdx_id_dadguide(nm))] = entry - - with open(BASENAMES_EXPORT_PATH, 'w', encoding='utf-8') as f: - json.dump(results, f) - - def _csv_to_tuples(self, file_path: str, cols: int = 2): - # Loads a two-column CSV into an array of tuples. - results = [] - with open(file_path, encoding='utf-8') as f: - file_reader = csv.reader(f, delimiter=',') - for row in file_reader: - if len(row) < 2: - continue - - data = [None] * cols - for i in range(0, min(cols, len(row))): - data[i] = row[i].strip() - - if not len(data[0]): - continue - - results.append(data) - return results - - async def _download_files(self): - one_hour_secs = 1 * 60 * 60 - await rpadutils.async_cached_dadguide_request(DB_DUMP_FILE, DB_DUMP_URL, one_hour_secs) - - async def _download_override_files(self): - one_hour_secs = 1 * 60 * 60 - await rpadutils.makeAsyncCachedPlainRequest( - NICKNAME_FILE_PATTERN, NICKNAME_OVERRIDES_SHEET, one_hour_secs) - await rpadutils.makeAsyncCachedPlainRequest( - BASENAME_FILE_PATTERN, GROUP_BASENAMES_OVERRIDES_SHEET, one_hour_secs) - await rpadutils.makeAsyncCachedPlainRequest( - PANTHNAME_FILE_PATTERN, PANTHNAME_OVERRIDES_SHEET, one_hour_secs) - - @commands.group() - @checks.is_owner() - async def dadguide(self, ctx): - """Dadguide database settings""" - - @dadguide.command() - @checks.is_owner() - async def setdatafile(self, ctx, *, data_file): - """Set a local path to dadguide data instead of downloading it.""" - self.settings.set_data_file(data_file) - await ctx.send(inline('Done')) - - - - -class DadguideSettings(rpadutils.CogSettings): - def make_default_settings(self): - config = { - 'data_file': '', - } - return config - - def data_file(self): - return self.bot_settings['data_file'] - - def set_data_file(self, data_file): - self.bot_settings['data_file'] = data_file - self.save_settings() - - -class Attribute(Enum): - """Standard 5 PAD colors in enum form. Values correspond to DadGuide values.""" - Fire = 0 - Water = 1 - Wood = 2 - Light = 3 - Dark = 4 - - -class MonsterType(Enum): - Evolve = 0 - Balance = 1 - Physical = 2 - Healer = 3 - Dragon = 4 - God = 5 - Attacker = 6 - Devil = 7 - Machine = 8 - Awoken = 12 - Enhance = 14 - Vendor = 15 - - -class EvoType(Enum): - """Evo types supported by DadGuide. Numbers correspond to their id values.""" - Base = 0 # Represents monsters who didn't require evo - Evo = 1 - UvoAwoken = 2 - UuvoReincarnated = 3 - - -class InternalEvoType(Enum): - """Evo types unsupported by DadGuide.""" - Base = "" - Normal = "" - Ultimate = "" - Reincarnated = "Reincarnated" - Assist = "Assist" - Pixel = "Pixel" - SuperReincarnated = "Super Reincarnated" - - -class Server(Enum): - JP = 0 - NA = 1 - KR = 2 - - -class DadguideTableNotFound(Exception): - def __init__(self, table_name): - self.message = '{} not found'.format(table_name) - - -def load_database(existing_db): - # Release the handle to the database file if it has one - if existing_db: - existing_db.close() - # Overwrite the working copy so we can open a handle to it without affecting future downloads - if os.path.exists(DB_DUMP_FILE): - shutil.copy2(DB_DUMP_FILE, DB_DUMP_WORKING_FILE) - # Open the new working copy. - return DadguideDatabase(data_file=DB_DUMP_WORKING_FILE) - - -class DadguideDatabase(object): - def __init__(self, data_file=None): - self._con = None - - if data_file is not None: - self._con = lite.connect(data_file, detect_types=lite.PARSE_DECLTYPES) - self._con.row_factory = lite.Row - - def has_database(self): - return self._con is not None - - def close(self): - self._con.close() - self._con = None - - @staticmethod - def _select_builder(tables, key=None, where=None, order=None, distinct=False): - if distinct: - SELECT_FROM = 'SELECT DISTINCT {fields} FROM {first_table}' - else: - SELECT_FROM = 'SELECT {fields} FROM {first_table}' - WHERE = 'WHERE {condition}' - JOIN = 'LEFT JOIN {other_table} ON {first_table}.{key}={other_table}.{key}' - ORDER = 'ORDER BY {order}' - first_table = None - fields_lst = [] - other_tables = [] - for table, fields in tables.items(): - if fields is not None: - fields_lst.extend(['{}.{}'.format(table, f) for f in fields]) - if first_table is None: - first_table = table - if key is None: - break - else: - other_tables.append(table) - query = [SELECT_FROM.format(first_table=first_table, fields=', '.join(fields_lst))] - prev_table = first_table - if key: - for k, other in zip(key, other_tables): - query.append(JOIN.format(first_table=prev_table, other_table=other, key=k)) - prev_table = other - if where: - query.append(WHERE.format(condition=where)) - if order: - query.append(ORDER.format(order=order)) - return ' '.join(query) - - def _query_one(self, query, param, d_type): - cursor = self._con.cursor() - cursor.execute(query, param) - res = cursor.fetchone() - if res is not None: - if issubclass(d_type, DadguideItem): - return d_type(res, self) - else: - return d_type(res) - return None - - def _as_generator(self, cursor, d_type): - res = cursor.fetchone() - if issubclass(d_type, DadguideItem): - while res is not None: - yield d_type(res, self) - res = cursor.fetchone() - else: - while res is not None: - yield d_type(res) - res = cursor.fetchone() - - def _query_many(self, query, param, d_type, idx_key=None, as_generator=False): - cursor = self._con.cursor() - cursor.execute(query, param) - if cursor.rowcount == 0: - return [] - if as_generator: - return self._as_generator(cursor, d_type) - else: - if idx_key is None: - return [d_type(res, self) for res in cursor.fetchall()] - else: - return DictWithAttrAccess({res[idx_key]: d_type(res, self) for res in cursor.fetchall()}) - - def _max_id(self): - cursor = self._con.cursor() - cursor.execute("SELECT MAX(monster_id) FROM monsters WHERE monster_id < 10000") - return cursor.fetchone()['MAX(monster_id)'] - - def _select_one_entry_by_pk(self, pk, d_type): - return self._query_one( - self._select_builder( - tables={d_type.TABLE: d_type.FIELDS}, - where='{}.{}=?'.format(d_type.TABLE, d_type.PK)), - (pk,), - d_type) - - def _get_table_fields(self, table_name: str): - # SQL inject vulnerable :v - table_info = self._query_many('PRAGMA table_info(' + table_name + ')', (), dict) - pk = None - fields = [] - for c in table_info: - if c['pk'] == 1: - pk = c['name'] - fields.append(c['name']) - if len(fields) == 0: - raise DadguideTableNotFound(table_name) - return fields, pk - - def get_active_skill(self, active_skill_id: int): - return self._select_one_entry_by_pk(active_skill_id, DgActiveSkill) - - def get_leader_skill(self, leader_skill_id: int): - return self._select_one_entry_by_pk(leader_skill_id, DgLeaderSkill) - - def get_awoken_skill(self, awoken_skill_id): - return self._select_one_entry_by_pk(awoken_skill_id, DgAwokenSkill) - - def get_awoken_skill_ids(self): - SELECT_AWOKEN_SKILL_IDS = 'SELECT awoken_skill_id from awoken_skills' - return [r.awoken_skill_id for r in - self._query_many(SELECT_AWOKEN_SKILL_IDS, (), DadguideItem, as_generator=True)] - - def get_monsters_by_awakenings(self, awoken_skill_id: int): - return self._query_many( - self._select_builder( - tables=OrderedDict({ - DgMonster.TABLE: DgMonster.FIELDS, - DgAwakening.TABLE: None, - }), - where='{}.awoken_skill_id=?;'.format(DgAwakening.TABLE), - key=('monster_id',), - distinct=True - ), - (awoken_skill_id,), - DgMonster) - - def get_awakenings_by_monster(self, monster_id, is_super=None): - if is_super is None: - where = '{0}.monster_id=?'.format(DgAwakening.TABLE) - param = (monster_id,) - else: - where = '{0}.monster_id=? AND {0}.is_super=?'.format(DgAwakening.TABLE) - param = (monster_id, is_super) - return self._query_many( - self._select_builder( - tables={DgAwakening.TABLE: DgAwakening.FIELDS}, - where=where, - order='order_idx ASC' - ), - param, - DgAwakening) - - def get_drop_dungeons(self, monster_id): - return self._query_many( - self._select_builder( - tables=OrderedDict({ - DgDungeon.TABLE: DgDungeon.FIELDS, - DgEncounter.TABLE: None, - DgDrop.TABLE: None, - }), - where='{0}.monster_id=?'.format(DgDrop.TABLE), - key=(DgDungeon.PK, DgEncounter.PK) - ), - (monster_id,), - DgDungeon) - - def monster_is_farmable(self, monster_id): - return self._query_one( - self._select_builder( - tables={DgDrop.TABLE: DgDrop.FIELDS}, - where='{0}.monster_id=?'.format(DgDrop.TABLE) - ), - (monster_id,), - DgDrop) is not None - - def monster_in_rem(self, monster_id): - return self._query_one( - self._select_builder( - tables={DgMonster.TABLE: ('rem_egg',)}, - where='{0}.monster_id=? AND rem_egg=1'.format(DgMonster.TABLE) - ), - (monster_id,), - DgDrop) is not None - - def monster_in_pem(self, monster_id): - return self._query_one( - self._select_builder( - tables={DgMonster.TABLE: ('pal_egg',)}, - where='{0}.monster_id=? AND pal_egg=1'.format(DgMonster.TABLE) - ), - (monster_id,), - DgDrop) is not None - - def monster_in_mp_shop(self, monster_id): - return self._query_one( - self._select_builder( - tables={DgMonster.TABLE: ('buy_mp',)}, - where='{0}.monster_id=? AND buy_mp IS NOT NULL'.format(DgMonster.TABLE) - ), - (monster_id,), - DgDrop) is not None - - def get_prev_evolution_by_monster(self, monster_id): - return self._query_one( - self._select_builder( - tables={DgEvolution.TABLE: DgEvolution.FIELDS}, - where='{}.to_id=?'.format(DgEvolution.TABLE) - ), - (monster_id,), - DgEvolution) - - def get_next_evolutions_by_monster(self, monster_id): - return self._query_many( - self._select_builder( - tables={DgEvolution.TABLE: DgEvolution.FIELDS}, - where='{}.from_id=?'.format(DgEvolution.TABLE) - ), - (monster_id,), - DgEvolution, - as_generator=True) - - def get_base_monster_by_monster(self, monster_id): - base = {'from_id': monster_id, 'to_id': monster_id} - lastbase = None - while base != None: - lastbase = base - base = self.get_prev_evolution_by_monster(base['from_id']) - return lastbase - - def get_all_evolutions_by_monster(self, monster_id): - base = self.get_base_monster_by_monster(monster_id) - queue = [base] - - while queue: - curr = queue.pop(0) - yield curr - queue.extend(self.get_next_evolutions_by_monster(curr['to_id'])) - - def get_evolution_by_material(self, monster_id): - return self._query_many( - self._select_builder( - tables={DgEvolution.TABLE: DgEvolution.FIELDS}, - where=' OR '.join(['{}.mat_{}_id=?'.format(DgEvolution.TABLE, i) for i in range(1, 6)]) - ), - (monster_id,) * 5, - DgEvolution) - - def get_base_monster_ids(self): - SELECT_BASE_MONSTER_ID = ''' - SELECT evolutions.from_id as monster_id FROM evolutions WHERE evolutions.from_id NOT IN (SELECT DISTINCT evolutions.to_id FROM evolutions) - UNION - SELECT monsters.monster_id FROM monsters WHERE monsters.monster_id NOT IN (SELECT evolutions.from_id FROM evolutions UNION SELECT evolutions.to_id FROM evolutions)''' - return self._query_many( - SELECT_BASE_MONSTER_ID, - (), - DictWithAttrAccess, - as_generator=True) - - def get_evolution_tree_ids(self, base_monster_id): - # is not a tree i lied - base_id = base_monster_id - evolution_tree = [base_id] - n_evos = deque() - n_evos.append(base_id) - while len(n_evos) > 0: - n_evo_id = n_evos.popleft() - for e in self.get_next_evolutions_by_monster(n_evo_id): - n_evos.append(e.to_id) - evolution_tree.append(e.to_id) - return evolution_tree - - def monster_id_to_no(self, monster_id, region=Server.JP): - res = self._query_one( - self._select_builder( - tables={DgMonster.TABLE: ('monster_no_{}'.format(region.name.lower()),)}, - where='{}.monster_id=?'.format(DgMonster.TABLE) - ), - (monster_id,), - DictWithAttrAccess) - for k in res: - return res[k] - - def get_series(self, series_id: int): - return self._select_one_entry_by_pk(series_id, DgSeries) - - def _get_monsters_where(self, where, param): - return self._query_many( - self._select_builder( - tables={DgMonster.TABLE: DgMonster.FIELDS}, - where=where - ), - param, - DgMonster) - - def get_monsters_by_series(self, series_id: int): - return self._get_monsters_where('{}.series_id=?'.format(DgMonster.TABLE), (series_id,)) - - def get_monsters_by_active(self, active_skill_id: int): - return self._get_monsters_where('{}.active_skill_id=?'.format(DgMonster.TABLE), (active_skill_id,)) - - def get_monster_evo_gem(self, name: str, region='jp'): - gem_suffix = { - 'jp': 'の希石', - 'na': '\'s Gem', - 'kr': ' 의 휘석' - } - if region not in gem_suffix: - return None - non_gem_name = name.replace(gem_suffix[region], '') - if non_gem_name == name: - return None - return self._query_one( - self._select_builder( - tables={DgMonster.TABLE: DgMonster.FIELDS}, - where='{0}.name_{1}=? AND {0}.leader_skill_id=10628'.format(DgMonster.TABLE, region) - ), - (non_gem_name,), - DgMonster) - - def get_na_only_monsters(self): - return self._get_monsters_where( - '{0}.monster_id != {0}.monster_no_na AND {0}.monster_no_jp == {0}.monster_no_na '.format(DgMonster.TABLE), - ()) - - def get_monster(self, monster_id: int): - return self._select_one_entry_by_pk(monster_id, DgMonster) - - def get_all_monster_jp_name(self, as_generator=True): - return self._query_many(self._select_builder(tables={DgMonster.TABLE: ('name_jp',)}), (), DictWithAttrAccess, - as_generator=as_generator) - - def get_all_monsters(self, as_generator=True): - return self._query_many(self._select_builder(tables={DgMonster.TABLE: DgMonster.FIELDS}), (), DgMonster, - as_generator=as_generator) - - - def get_all_events(self, as_generator=True): - return self._query_many(self._select_builder(tables={DgScheduledEvent.TABLE: DgScheduledEvent.FIELDS}), (), DgScheduledEvent, - as_generator=as_generator) - - def get_dungeon_by_id(self, dungeon_id: int): - return self._select_one_entry_by_pk(dungeon_id, DgDungeon) - - def get_dungeon_by_id(self, dungeon_id: int): - return self._select_one_entry_by_pk(dungeon_id, DgDungeon) - - - -def enum_or_none(enum, value, default=None): - if value is not None: - return enum(value) - else: - return default - - -class DictWithAttrAccess(dict): - def __init__(self, item): - super(DictWithAttrAccess, self).__init__(item) - self.__dict__ = self - - -class DadguideItem(DictWithAttrAccess): - """ - Base class for all items loaded from DadGuide. - Is a dict with attr access. - """ - TABLE = None - FIELDS = '*' - PK = None - AS_BOOL = () - - def __init__(self, item, database): - super(DadguideItem, self).__init__(item) - self._database = database - for k in self.AS_BOOL: - self[k] = bool(self[k]) - - def key(self): - return self[self.PK] - - -class DgActiveSkill(DadguideItem): - TABLE = 'active_skills' - PK = 'active_skill_id' - - @property - def monsters(self): - return self._database.get_monsters_by_active(self.active_skill_id) - - @property - def skillups(self): - return list(filter(lambda x: x.farmable, self.monsters)) - - @property - def desc(self): - return self.desc_na or self.desc_jp - - @property - def name(self): - return self.name_na or self.name_jp - - -class DgLeaderSkill(DadguideItem): - TABLE = 'leader_skills' - PK = 'leader_skill_id' - - @property - def data(self): - return self.max_hp, self.max_atk, self.max_rcv, self.max_shield - - @property - def desc(self): - return self.desc_na or self.desc_jp - - @property - def name(self): - return self.name_na or self.name_jp - - -class DgAwakening(DadguideItem): - TABLE = 'awakenings' - PK = 'awakening_id' - AS_BOOL = ['is_super'] - - def __init__(self, item, database): - super(DgAwakening, self).__init__(item, database) - - @property - def skill(self): - return self._database.get_awoken_skill(self.awoken_skill_id) - - @property - def name(self): - return self.skill.name_na if self.skill.name_na is not None else self.skill.name_jp - - -class DgAwokenSkill(DadguideItem): - TABLE = 'awoken_skills' - PK = 'awoken_skill_id' - - @property - def monsters_with_awakening(self): - return self._database.get_monsters_by_awakenings(self.awoken_skill_id) - - -class DgEvolution(DadguideItem): - TABLE = 'evolutions' - PK = 'evolution_id' - - def __init__(self, item, database): - super(DgEvolution, self).__init__(item, database) - self.evolution_type = EvoType(self.evolution_type) - - -class DgSeries(DadguideItem): - TABLE = 'series' - PK = 'series_id' - - @property - def monsters(self): - return self._database.get_monsters_by_series(self.series_id) - - @property - def name(self): - return self.name_na if self.name_na is not None else self.name_jp - - -class DgDungeon(DadguideItem): - TABLE = 'dungeons' - PK = 'dungeon_id' - - -class DgEncounter(DadguideItem): - TABLE = 'encounters' - PK = 'encounter_id' - - -class DgDrop(DadguideItem): - TABLE = 'drops' - PK = 'drop_id' - - -class DgScheduledEvent(DadguideItem): - TABLE = 'schedule' - PK = 'event_id' - - def __init__(self, item, database): - super(DgScheduledEvent, self).__init__(item, database) - self.dungeon = self._database.get_dungeon_by_id(self.dungeon_id) - - @property - def open_datetime(self): - return datetime.utcfromtimestamp(self.start_timestamp).replace(tzinfo=pytz.UTC) - - @open_datetime.setter - def open_datetime(self, value): - self.start_timestamp = int(value.timestamp()) - - @property - def close_datetime(self): - return datetime.utcfromtimestamp(self.end_timestamp).replace(tzinfo=pytz.UTC) - - @close_datetime.setter - def close_datetime(self, value): - self.end_timestamp = int(value.timestamp()) - - - -class DgMonster(DadguideItem): - TABLE = 'monsters' - PK = 'monster_id' - AS_BOOL = ('on_jp', 'on_na', 'on_kr', 'has_animation', 'has_hqimage') - - def __init__(self, item, database): - super(DgMonster, self).__init__(item, database) - - self.roma_subname = None - if self.name_na == self.name_jp: - self.roma_subname = make_roma_subname(self.name_jp) - else: - # Remove annoying stuff from NA names, like Jörmungandr - self.name_na = rpadutils.rmdiacritics(self.name_na) - - self.name_na = self.name_na_override or self.name_na - - self.attr1 = enum_or_none(Attribute, self.attribute_1_id) - self.attr2 = enum_or_none(Attribute, self.attribute_2_id) - - self.type1 = enum_or_none(MonsterType, self.type_1_id) - self.type2 = enum_or_none(MonsterType, self.type_2_id) - self.type3 = enum_or_none(MonsterType, self.type_3_id) - self.types = list(filter(None, [self.type1, self.type2, self.type3])) - - self.in_pem = bool(self.pal_egg) - self.in_rem = bool(self.rem_egg) - - self.awakenings = self._database.get_awakenings_by_monster(self.monster_id) - self.superawakening_count = sum(int(a.is_super) for a in self.awakenings) - - self.is_inheritable = bool(self.inheritable) - - self.evo_from = self._database.get_prev_evolution_by_monster(self.monster_id) - - self.is_equip = any([x.awoken_skill_id == 49 for x in self.awakenings]) - - base_id = self.monster_id - next_base = self._database.get_prev_evolution_by_monster(base_id) - while next_base is not None: - base_id = next_base.from_id - next_base = self._database.get_prev_evolution_by_monster(base_id) - self._base_monster_id = base_id - self._alt_evo_id_list = self._database.get_evolution_tree_ids(self._base_monster_id) - - self.search = MonsterSearchHelper(self) - - @property - def monster_no(self): - return self.monster_id - - def stat(self, key, lv, plus=99, inherit=False, is_plus_297=True): - s_min = float(self[key + '_min']) - s_max = float(self[key + '_max']) - if self.level > 1: - s_val = s_min + (s_max - s_min) * ((min(lv, self.level) - 1) / (self.level - 1)) ** self[key + '_scale'] - else: - s_val = s_min - if lv > 99: - s_val *= 1 + (self.limit_mult / 11 * (lv - 99)) / 100 - plus_dict = {'hp': 10, 'atk': 5, 'rcv': 3} - s_val += plus_dict[key] * max(min(plus, 99), 0) - if inherit: - inherit_dict = {'hp': 0.10, 'atk': 0.05, 'rcv': 0.15} - if not is_plus_297: - s_val -= plus_dict[key] * max(min(plus, 99), 0) - s_val *= inherit_dict[key] - return int(round(s_val)) - - def stats(self, lv=99, plus=0, inherit=False): - is_plus_297 = False - if plus == 297: - plus = (99, 99, 99) - is_plus_297 = True - elif plus == 0: - plus = (0, 0, 0) - hp = self.stat('hp', lv, plus[0], inherit, is_plus_297) - atk = self.stat('atk', lv, plus[1], inherit, is_plus_297) - rcv = self.stat('rcv', lv, plus[2], inherit, is_plus_297) - weighted = int(round(hp / 10 + atk / 5 + rcv / 3)) - return hp, atk, rcv, weighted - - @property - def active_skill(self): - return self._database.get_active_skill(self.active_skill_id) - - @property - def leader_skill(self): - return self._database.get_leader_skill(self.leader_skill_id) - - @property - def cur_evo_type(self): - return self.evo_from.evolution_type if self.evo_from else EvoType.Base - - @property - def true_evo_type(self): - if self == self.base_monster: - return InternalEvoType.Base - elif 5077 in [dgi.monster_id for dgi in self.mats_for_evo]: - return InternalEvoType.SuperReincarnated - elif 3826 in [dgi.monster_id for dgi in self.mats_for_evo]: - return InternalEvoType.Pixel - elif self.is_equip: - return InternalEvoType.Assist - elif self.cur_evo_type == EvoType.UuvoReincarnated: - return InternalEvoType.Reincarnated - elif self.cur_evo_type == EvoType.UvoAwoken: - return InternalEvoType.Ultimate - else: - return InternalEvoType.Normal - - @property - def mats_for_evo(self): - if self.evo_from is None: - return [] - return [self._database.get_monster(self.evo_from['mat_{}_id'.format(i)]) for i in range(1, 6) if - self.evo_from['mat_{}_id'.format(i)] is not None] - - @property - def evo_gem(self): - return self._database.get_monster_evo_gem(self.name_jp) - - @property - def material_of(self): - mat_of = self._database.get_evolution_by_material(self.monster_id) - return [self._database.get_monster(x.to_id) for x in mat_of] - - def _evolutions_to(self): - return self._database.get_next_evolutions_by_monster(self.monster_id) - - @property - def evo_to(self): - return [self._database.get_monster(x.to_id) for x in self._evolutions_to()] - - @property - def base_monster(self): - return self._database.get_monster(self._base_monster_id) - - @property - def alt_evos(self): - return [self._database.get_monster(a) for a in self._alt_evo_id_list] - - @property - def is_base_monster(self): - return self.evo_from is None - - @property - def series(self): - return self._database.get_series(self.series_id) - - @property - def is_gfe(self): - return self.series_id == 34 - - @property - def drop_dungeons(self): - return self._database.get_drop_dungeons(self.monster_id) - - @property - def farmable(self): - return self._database.monster_is_farmable(self.monster_id) - - @property - def farmable_evo(self): - for e_id in self._alt_evo_id_list: - if self._database.monster_is_farmable(e_id): - return True - return False - - @property - def rem_evo(self): - for e_id in self._alt_evo_id_list: - if self._database.monster_in_rem(e_id): - return True - return False - - @property - def pem_evo(self): - for e_id in self._alt_evo_id_list: - if self._database.monster_in_pem(e_id): - return True - return False - - @property - def killers(self): - type_to_killers_map = { - MonsterType.God: ['Devil'], - MonsterType.Devil: ['God'], - MonsterType.Machine: ['God', 'Balance'], - MonsterType.Dragon: ['Machine', 'Healer'], - MonsterType.Physical: ['Machine', 'Healer'], - MonsterType.Attacker: ['Devil', 'Physical'], - MonsterType.Healer: ['Dragon', 'Attacker'], - } - if MonsterType.Balance in self.types: - return ['Any'] - killers = set() - for t in self.types: - killers.update(type_to_killers_map.get(t, [])) - return sorted(killers) - - @property - def in_mpshop(self): - return self.buy_mp is not None - - @property - def mp_evo(self): - for e_id in self._alt_evo_id_list: - if self._database.monster_in_mp_shop(e_id): - return True - return False - - @property - def history_us(self): - return '[{}] New Added'.format(self.reg_date) - - @property - def next_monster(self): - next = None - offset = 1 - while next is None and self.monster_no + offset <= self._database._max_id(): - next = self._database.get_monster(self.monster_no + offset) - offset += 1 - return next - - @property - def prev_monster(self): - next = None - offset = 1 - while next is None and self.monster_no - offset >= 1: - next = self._database.get_monster(self.monster_no - offset) - offset += 1 - return next - - -class MonsterSearchHelper(object): - def __init__(self, m: DgMonster): - - self.name = '{} {}'.format(m.name_na, m.name_jp).lower() - leader_skill = m.leader_skill - self.leader = leader_skill.desc.lower() if leader_skill else '' - active_skill = m.active_skill - self.active_name = active_skill.name.lower() if active_skill else '' - self.active_desc = active_skill.desc.lower() if active_skill else '' - self.active = '{} {}'.format(self.active_name, self.active_desc) - self.active_min = active_skill.turn_min if active_skill else None - self.active_max = active_skill.turn_max if active_skill else None - - self.color = [m.attr1.name.lower()] - self.hascolor = [c.name.lower() for c in [m.attr1, m.attr2] if c] - - self.hp, self.atk, self.rcv, self.weighted_stats = m.stats(lv=110) - - self.types = [t.name for t in m.types] - - def replace_colors(text: str): - return text.replace('red', 'fire').replace('blue', 'water').replace('green', 'wood') - - self.leader = replace_colors(self.leader) - self.active = replace_colors(self.active) - self.active_name = replace_colors(self.active_name) - self.active_desc = replace_colors(self.active_desc) - - self.board_change = [] - self.orb_convert = defaultdict(list) - self.row_convert = [] - self.column_convert = [] - - def color_txt_to_list(txt): - txt = txt.replace('and', ' ') - txt = txt.replace(',', ' ') - txt = txt.replace('orbs', ' ') - txt = txt.replace('orb', ' ') - txt = txt.replace('mortal poison', 'mortalpoison') - txt = txt.replace('jammers', 'jammer') - txt = txt.strip() - return txt.split() - - def strip_prev_clause(txt: str, sep: str): - prev_clause_start_idx = txt.find(sep) - if prev_clause_start_idx >= 0: - prev_clause_start_idx += len(sep) - txt = txt[prev_clause_start_idx:] - return txt - - def strip_next_clause(txt: str, sep: str): - next_clause_start_idx = txt.find(sep) - if next_clause_start_idx >= 0: - txt = txt[:next_clause_start_idx] - return txt - - active_desc = self.active_desc - active_desc = active_desc.replace(' rows ', ' row ') - active_desc = active_desc.replace(' columns ', ' column ') - active_desc = active_desc.replace(' into ', ' to ') - active_desc = active_desc.replace('changes orbs to', 'all orbs to') - - board_change_txt = 'all orbs to' - if board_change_txt in active_desc: - txt = strip_prev_clause(active_desc, board_change_txt) - txt = strip_next_clause(txt, 'orbs') - txt = strip_next_clause(txt, ';') - self.board_change = color_txt_to_list(txt) - - txt = active_desc - if 'row' in txt: - parts = re.split(r'\Wand\W|;\W', txt) - for i in range(0, len(parts)): - if 'row' in parts[i]: - self.row_convert.append(strip_next_clause( - strip_prev_clause(parts[i], 'to '), ' orbs')) - - txt = active_desc - if 'column' in txt: - parts = re.split(r'\Wand\W|;\W', txt) - for i in range(0, len(parts)): - if 'column' in parts[i]: - self.column_convert.append(strip_next_clause( - strip_prev_clause(parts[i], 'to '), ' orbs')) - - convert_done = self.board_change or self.row_convert or self.column_convert - - change_txt = 'change ' - if not convert_done and change_txt in active_desc and 'orb' in active_desc: - txt = active_desc - parts = re.split(r'\Wand\W|;\W', txt) - for i in range(0, len(parts)): - parts[i] = strip_prev_clause(parts[i], change_txt) if change_txt in parts[i] else '' - - for part in parts: - sub_parts = part.split(' to ') - if len(sub_parts) > 1: - source_orbs = color_txt_to_list(sub_parts[0]) - dest_orbs = color_txt_to_list(sub_parts[1]) - for so in source_orbs: - for do in dest_orbs: - self.orb_convert[so].append(do) - - -def make_roma_subname(name_jp): - subname = name_jp.replace('=', '') - adjusted_subname = '' - for part in subname.split('・'): - roma_part = romkan.to_roma(part) - if part != roma_part and not rpadutils.containsJp(roma_part): - adjusted_subname += ' ' + roma_part.strip('-') - return adjusted_subname.strip() - - -def int_or_none(maybe_int: str): - return int(maybe_int) if maybe_int else None - - -def float_or_none(maybe_float: str): - return float(maybe_float) if maybe_float else None - - -class MonsterIndex(object): - def __init__(self, monster_database, nickname_overrides, basename_overrides, panthname_overrides, - accept_filter=None): - # Important not to hold onto anything except IDs here so we don't leak memory - base_monster_ids = monster_database.get_base_monster_ids() - - self.attr_short_prefix_map = { - Attribute.Fire: ['r'], - Attribute.Water: ['b'], - Attribute.Wood: ['g'], - Attribute.Light: ['l'], - Attribute.Dark: ['d'], - } - self.attr_long_prefix_map = { - Attribute.Fire: ['red', 'fire'], - Attribute.Water: ['blue', 'water'], - Attribute.Wood: ['green', 'wood'], - Attribute.Light: ['light'], - Attribute.Dark: ['dark'], - } - - self.series_to_prefix_map = { - 130: ['halloween', 'hw', 'h'], - 136: ['xmas', 'christmas'], - 125: ['summer', 'beach'], - 114: ['school', 'academy', 'gakuen'], - 139: ['new years', 'ny'], - 149: ['wedding', 'bride'], - 154: ['padr'], - 175: ['valentines', 'vday', 'v'], - } - - monster_id_to_nicknames = defaultdict(set) - for nickname, monster_id in nickname_overrides.items(): - monster_id_to_nicknames[monster_id].add(nickname) - - named_monsters = [] - for base_mon in base_monster_ids: - base_id = base_mon.monster_id - group_basename_overrides = basename_overrides.get(base_id, []) - evolution_tree = [monster_database.get_monster(m) for m in - monster_database.get_evolution_tree_ids(base_id)] - named_mg = NamedMonsterGroup(evolution_tree, group_basename_overrides) - for monster in evolution_tree: - if accept_filter and not accept_filter(monster): - continue - prefixes = self.compute_prefixes(monster, evolution_tree) - extra_nicknames = monster_id_to_nicknames[monster.monster_id] - named_monster = NamedMonster(monster, named_mg, prefixes, extra_nicknames) - named_monsters.append(named_monster) - - # Sort the NamedMonsters into the opposite order we want to accept their nicknames in - # This order is: - # 1) High priority first - # 2) Larger group sizes - # 3) Minimum ID size in the group - # 4) Monsters with higher ID values - def named_monsters_sort(nm: NamedMonster): - return (not nm.is_low_priority, nm.group_size, -1 * - nm.base_monster_no_na, nm.monster_no_na) - - named_monsters.sort(key=named_monsters_sort) - - # set up a set of all pantheon names, a set of all pantheon nicknames, and a dictionary of nickname -> full name - # later we will set up a dictionary of pantheon full name -> monsters - self.all_pantheon_names = set() - self.all_pantheon_names.update(panthname_overrides.values()) - - self.pantheon_nick_to_name = panthname_overrides - self.pantheon_nick_to_name.update(panthname_overrides) - - self.all_pantheon_nicknames = set() - self.all_pantheon_nicknames.update(panthname_overrides.keys()) - - self.all_prefixes = set() - self.pantheons = defaultdict(set) - self.all_entries = {} - self.two_word_entries = {} - for nm in named_monsters: - self.all_prefixes.update(nm.prefixes) - for nickname in nm.final_nicknames: - self.all_entries[nickname] = nm - for nickname in nm.final_two_word_nicknames: - self.two_word_entries[nickname] = nm - if nm.series: - for pantheon in self.all_pantheon_names: - if pantheon.lower() == nm.series.lower(): - self.pantheons[pantheon.lower()].add(nm) - - self.all_monsters = named_monsters - self.all_na_name_to_monsters = {m.name_na.lower(): m for m in named_monsters} - self.monster_no_na_to_named_monster = {m.monster_no_na: m for m in named_monsters} - self.monster_id_to_named_monster = {m.monster_id: m for m in named_monsters} - - for nickname, monster_id in nickname_overrides.items(): - nm = self.monster_id_to_named_monster.get(monster_id) - if nm: - self.all_entries[nickname] = nm - - def init_index(self): - pass - - def compute_prefixes(self, m: DgMonster, evotree: list): - prefixes = set() - - attr1_short_prefixes = self.attr_short_prefix_map[m.attr1] - attr1_long_prefixes = self.attr_long_prefix_map[m.attr1] - prefixes.update(attr1_short_prefixes) - prefixes.update(attr1_long_prefixes) - - # If no 2nd attribute, use x so we can look those monsters up easier - attr2_short_prefixes = self.attr_short_prefix_map.get(m.attr2, ['x']) - for a1 in attr1_short_prefixes: - for a2 in attr2_short_prefixes: - prefixes.add(a1 + a2) - prefixes.add(a1 + '/' + a2) - - # TODO: add prefixes based on type - - # Chibi monsters have the same NA name, except lowercased - lower_name = m.name_na.lower() - if m.name_na != m.name_jp: - if lower_name == m.name_na: - prefixes.add('chibi') - elif 'ミニ' in m.name_jp: - # Guarding this separately to prevent 'gemini' from triggering (e.g. 2645) - prefixes.add('chibi') - - awoken = lower_name.startswith('awoken') or '覚醒' in lower_name - revo = lower_name.startswith('reincarnated') or '転生' in lower_name - srevo = lower_name.startswith('super reincarnated') or '超転生' in lower_name - mega = lower_name.startswith('mega awoken') or '極醒' in lower_name - awoken_or_revo_or_equip_or_mega = awoken or revo or m.is_equip or mega - - # These clauses need to be separate to handle things like 'Awoken Thoth' which are - # actually Evos but have awoken in the name - if awoken: - prefixes.add('a') - prefixes.add('awoken') - - if revo: - prefixes.add('revo') - prefixes.add('reincarnated') - - if mega: - prefixes.add('mega') - prefixes.add('mega awoken') - prefixes.add('awoken') - - if srevo: - prefixes.add('srevo') - prefixes.add('super reincarnated') - - # Prefixes for evo type - if m.cur_evo_type == EvoType.Base: - prefixes.add('base') - elif m.cur_evo_type == EvoType.Evo: - prefixes.add('evo') - elif m.cur_evo_type == EvoType.UvoAwoken and not awoken_or_revo_or_equip_or_mega: - prefixes.add('uvo') - prefixes.add('uevo') - elif m.cur_evo_type == EvoType.UuvoReincarnated and not awoken_or_revo_or_equip_or_mega: - prefixes.add('uuvo') - prefixes.add('uuevo') - - # If any monster in the group is a pixel, add 'nonpixel' to all the versions - # without pixel in the name. Add 'pixel' as a prefix to the ones with pixel in the name. - def is_pixel(n): - n = n.name_na.lower() - return n.startswith('pixel') or n.startswith('ドット') - - for gm in evotree: - if is_pixel(gm): - prefixes.update(['pixel'] if is_pixel(m) else ['np', 'nonpixel']) - break - - if m.is_equip: - prefixes.add('assist') - prefixes.add('equip') - - # Collab prefixes - prefixes.update(self.series_to_prefix_map.get(m.series.series_id, [])) - - return prefixes - - def find_monster(self, query): - query = rpadutils.rmdiacritics(query).lower().strip() - - # id search - if query.isdigit(): - m = self.monster_no_na_to_named_monster.get(int(query)) - if m is None: - return None, 'Looks like a monster ID but was not found', None - else: - return m, None, "ID lookup" - # special handling for na/jp - - # TODO: need to handle na_only? - - # handle exact nickname match - if query in self.all_entries: - return self.all_entries[query], None, "Exact nickname" - - contains_jp = rpadutils.containsJp(query) - if len(query) < 2 and contains_jp: - return None, 'Japanese queries must be at least 2 characters', None - elif len(query) < 4 and not contains_jp: - return None, 'Your query must be at least 4 letters', None - - # TODO: this should be a length-limited priority queue - matches = set() - # prefix search for nicknames, space-preceeded, take max id - for nickname, m in self.all_entries.items(): - if nickname.startswith(query + ' '): - matches.add(m) - if len(matches): - return self.pickBestMonster(matches), None, "Space nickname prefix, max of {}".format(len(matches)) - - # prefix search for nicknames, take max id - for nickname, m in self.all_entries.items(): - if nickname.startswith(query): - matches.add(m) - if len(matches): - all_names = ",".join(map(lambda x: x.name_na, matches)) - return self.pickBestMonster(matches), None, "Nickname prefix, max of {}, matches=({})".format( - len(matches), all_names) - - # prefix search for full name, take max id - for nickname, m in self.all_entries.items(): - if (m.name_na.lower().startswith(query) or m.name_jp.lower().startswith(query)): - matches.add(m) - if len(matches): - return self.pickBestMonster(matches), None, "Full name, max of {}".format(len(matches)) - - # for nicknames with 2 names, prefix search 2nd word, take max id - if query in self.two_word_entries: - return self.two_word_entries[query], None, "Second-word nickname prefix, max of {}".format(len(matches)) - - # TODO: refactor 2nd search characteristcs for 2nd word - - # full name contains on nickname, take max id - for nickname, m in self.all_entries.items(): - if (query in m.name_na.lower() or query in m.name_jp.lower()): - matches.add(m) - if len(matches): - return self.pickBestMonster(matches), None, 'Nickname contains nickname match ({})'.format( - len(matches)) - - # No decent matches. Try near hits on nickname instead - matches = difflib.get_close_matches(query, self.all_entries.keys(), n=1, cutoff=.8) - if len(matches): - match = matches[0] - return self.all_entries[match], None, 'Close nickname match ({})'.format(match) - - # Still no decent matches. Try near hits on full name instead - matches = difflib.get_close_matches( - query, self.all_na_name_to_monsters.keys(), n=1, cutoff=.9) - if len(matches): - match = matches[0] - return self.all_na_name_to_monsters[match], None, 'Close name match ({})'.format(match) - - # About to give up, try matching all words - matches = set() - for nickname, m in self.all_entries.items(): - if (all(map(lambda x: x in m.name_na.lower(), query.split())) or - all(map(lambda x: x in m.name_jp.lower(), query.split()))): - matches.add(m) - if len(matches): - return self.pickBestMonster(matches), None, 'All word match on full name, max of {}'.format( - len(matches)) - - # couldn't find anything - return None, "Could not find a match for: " + query, None - - def find_monster2(self, query): - """Search with alternative method for resolving prefixes. - - Implements the lookup for id2, where you are allowed to specify multiple prefixes for a card. - All prefixes are required to be exactly matched by the card. - Follows a similar logic to the regular id but after each check, will remove any potential match that doesn't - contain every single specified prefix. - """ - query = rpadutils.rmdiacritics(query).lower().strip() - # id search - if query.isdigit(): - m = self.monster_no_na_to_named_monster.get(int(query)) - if m is None: - return None, 'Looks like a monster ID but was not found', None - else: - return m, None, "ID lookup" - - # handle exact nickname match - if query in self.all_entries: - return self.all_entries[query], None, "Exact nickname" - - contains_jp = rpadutils.containsJp(query) - if len(query) < 2 and contains_jp: - return None, 'Japanese queries must be at least 2 characters', None - elif len(query) < 4 and not contains_jp: - return None, 'Your query must be at least 4 letters', None - - # we want to look up only the main part of the query, and then verify that each result has the prefixes - # so break up the query into an array of prefixes, and a string (new_query) that will be the lookup - query_prefixes = [] - parts_of_query = query.split() - new_query = '' - for i, part in enumerate(parts_of_query): - if part in self.all_prefixes: - query_prefixes.append(part) - else: - new_query = ' '.join(parts_of_query[i:]) - break - - # if we don't have any prefixes, then default to using the regular id lookup - if len(query_prefixes) < 1: - return self.find_monster(query) - - matches = PotentialMatches() - - # first try to get matches from nicknames - for nickname, m in self.all_entries.items(): - if new_query in nickname: - matches.add(m) - matches.remove_potential_matches_without_all_prefixes(query_prefixes) - - # if we don't have any candidates yet, pick a new method - if not matches.length(): - # try matching on exact names next - for nickname, m in self.all_na_name_to_monsters.items(): - if new_query in m.name_na.lower() or new_query in m.name_jp.lower(): - matches.add(m) - matches.remove_potential_matches_without_all_prefixes(query_prefixes) - - # check for exact match on pantheon name but only if needed - if not matches.length(): - for pantheon in self.all_pantheon_nicknames: - if new_query == pantheon.lower(): - matches.get_monsters_from_potential_pantheon_match(pantheon, self.pantheon_nick_to_name, - self.pantheons) - matches.remove_potential_matches_without_all_prefixes(query_prefixes) - - # check for any match on pantheon name, again but only if needed - if not matches.length(): - for pantheon in self.all_pantheon_nicknames: - if new_query in pantheon.lower(): - matches.get_monsters_from_potential_pantheon_match(pantheon, self.pantheon_nick_to_name, - self.pantheons) - matches.remove_potential_matches_without_all_prefixes(query_prefixes) - - if matches.length(): - return matches.pick_best_monster(), None, None - return None, "Could not find a match for: " + query, None - - def pickBestMonster(self, named_monster_list): - return max(named_monster_list, key=lambda x: (not x.is_low_priority, x.rarity, x.monster_no_na)) - - -class PotentialMatches(object): - def __init__(self): - self.match_list = set() - - def add(self, m): - self.match_list.add(m) - - def update(self, monster_list): - self.match_list.update(monster_list) - - def length(self): - return len(self.match_list) - - def remove_potential_matches_without_all_prefixes(self, query_prefixes): - to_remove = set() - for m in self.match_list: - for prefix in query_prefixes: - if prefix not in m.prefixes: - to_remove.add(m) - break - self.match_list.difference_update(to_remove) - - def get_monsters_from_potential_pantheon_match(self, pantheon, pantheon_nick_to_name, pantheons): - full_name = pantheon_nick_to_name[pantheon] - self.update(pantheons[full_name]) - - def pick_best_monster(self): - return max(self.match_list, key=lambda x: (not x.is_low_priority, x.rarity, x.monster_no_na)) - - -class NamedMonsterGroup(object): - def __init__(self, evolution_tree: list, basename_overrides: list): - self.is_low_priority = ( - self._is_low_priority_monster(evolution_tree[0]) - or self._is_low_priority_group(evolution_tree)) - - base_monster = evolution_tree[0] - self.group_size = len(evolution_tree) - self.base_monster_no = base_monster.monster_id - self.base_monster_no_na = base_monster.monster_no_na - - self.monster_no_to_basename = { - m.monster_id: self._compute_monster_basename(m) for m in evolution_tree - } - - self.computed_basename = self._compute_group_basename(evolution_tree) - self.computed_basenames = set([self.computed_basename]) - if '-' in self.computed_basename: - self.computed_basenames.add(self.computed_basename.replace('-', ' ')) - - self.basenames = basename_overrides or self.computed_basenames - - def _compute_monster_basename(self, m: DgMonster): - basename = m.name_na.lower() - if ',' in basename: - name_parts = basename.split(',') - if name_parts[1].strip().startswith('the '): - # handle names like 'xxx, the yyy' where xxx is the name - basename = name_parts[0] - else: - # otherwise, grab the chunk after the last comma - basename = name_parts[-1] - - for x in ['awoken', 'reincarnated']: - if basename.startswith(x): - basename = basename.replace(x, '') - - # Fix for DC collab garbage - basename = basename.replace('(comics)', '') - basename = basename.replace('(film)', '') - - return basename.strip() - - def _compute_group_basename(self, monsters): - """Computes the basename for a group of monsters. - - Prefer a basename with the largest count across the group. If all the - groups have equal size, prefer the lowest monster number basename. - This monster in general has better names, particularly when all the - names are unique, e.g. for male/female hunters.""" - - def count_and_id(): - return [0, 0] - - basename_to_info = defaultdict(count_and_id) - - for m in monsters: - basename = self.monster_no_to_basename[m.monster_id] - entry = basename_to_info[basename] - entry[0] += 1 - entry[1] = max(entry[1], m.monster_id) - - entries = [[count_id[0], -1 * count_id[1], bn] for bn, count_id in basename_to_info.items()] - return max(entries)[2] - - def _is_low_priority_monster(self, m: DgMonster): - lp_types = [MonsterType.Evolve, MonsterType.Enhance, MonsterType.Awoken, MonsterType.Vendor] - lp_substrings = ['tamadra'] - lp_min_rarity = 2 - name = m.name_na.lower() - - failed_type = m.type1 in lp_types - failed_ss = any([x in name for x in lp_substrings]) - failed_rarity = m.rarity < lp_min_rarity - failed_chibi = name == m.name_na and m.name_na != m.name_jp - failed_equip = m.is_equip - return failed_type or failed_ss or failed_rarity or failed_chibi or failed_equip - - def _is_low_priority_group(self, mg: list): - lp_grp_min_rarity = 5 - max_rarity = max(m.rarity for m in mg) - failed_max_rarity = max_rarity < lp_grp_min_rarity - return failed_max_rarity - - -class NamedMonster(object): - def __init__(self, monster: DgMonster, monster_group: NamedMonsterGroup, prefixes: set, extra_nicknames: set): - # Must not hold onto monster or monster_group! - - # Hold on to the IDs instead - self.monster_id = monster.monster_id - self.monster_no_na = monster.monster_no_na - self.monster_no_jp = monster.monster_no_jp - - # ID of the root of the tree for this monster - self.base_monster_no = monster_group.base_monster_no - self.base_monster_no_na = monster_group.base_monster_no_na - - # This stuff is important for nickname generation - self.group_basenames = monster_group.basenames - self.prefixes = prefixes - - # Pantheon - self.series = monster.series.name if monster.series else None - - # Data used to determine how to rank the nicknames - self.is_low_priority = monster_group.is_low_priority or monster.is_equip - self.group_size = monster_group.group_size - self.rarity = monster.rarity - - # Used in fallback searches - self.name_na = monster.name_na - self.name_jp = monster.name_jp - - # These are just extra metadata - self.monster_basename = monster_group.monster_no_to_basename[self.monster_id] - self.group_computed_basename = monster_group.computed_basename - self.extra_nicknames = extra_nicknames - - # Compute any extra prefixes - if self.monster_basename in ('ana', 'ace'): - self.prefixes.add(self.monster_basename) - - # Compute extra basenames by checking for two-word basenames and using the second half - self.two_word_basenames = set() - for basename in self.group_basenames: - basename_words = basename.split(' ') - if len(basename_words) == 2: - self.two_word_basenames.add(basename_words[1]) - - # The primary result nicknames - self.final_nicknames = set() - # Set the configured override nicknames - self.final_nicknames.update(self.extra_nicknames) - # Set the roma subname for JP monsters - if monster.roma_subname: - self.final_nicknames.add(monster.roma_subname) - - # For each basename, add nicknames - for basename in self.group_basenames: - # Add the basename directly - self.final_nicknames.add(basename) - # Add the prefix plus basename, and the prefix with a space between basename - for prefix in self.prefixes: - self.final_nicknames.add(prefix + basename) - self.final_nicknames.add(prefix + ' ' + basename) - - self.final_two_word_nicknames = set() - # Slightly different process for two-word basenames. Does this make sense? Who knows. - for basename in self.two_word_basenames: - self.final_two_word_nicknames.add(basename) - # Add the prefix plus basename, and the prefix with a space between basename - for prefix in self.prefixes: - self.final_two_word_nicknames.add(prefix + basename) - self.final_two_word_nicknames.add(prefix + ' ' + basename) diff --git a/damagecalc/__init__.py b/damagecalc/__init__.py index 2907cb11..c0864e7b 100644 --- a/damagecalc/__init__.py +++ b/damagecalc/__init__.py @@ -1,6 +1,8 @@ -from .damagecalc import * - +from .damagecalc import DamageCalc -def setup(bot): +__red_end_user_data_statement__ = "No personal data is stored." + + +async def setup(bot): n = DamageCalc(bot) - bot.add_cog(n) + bot.add_cog(n) if not __import__('asyncio').iscoroutinefunction(bot.add_cog) else await bot.add_cog(n) diff --git a/damagecalc/damagecalc.py b/damagecalc/damagecalc.py index 7a714e75..8ffe97d2 100644 --- a/damagecalc/damagecalc.py +++ b/damagecalc/damagecalc.py @@ -1,6 +1,7 @@ import math from ply import lex +from io import BytesIO from redbot.core import commands @@ -191,6 +192,18 @@ def __init__(self, bot, *args, **kwargs): super().__init__(*args, **kwargs) self.bot = bot + async def red_get_data_for_user(self, *, user_id): + """Get a user's personal data.""" + data = "No data is stored for user with ID {}.\n".format(user_id) + return {"user_data.txt": BytesIO(data.encode())} + + async def red_delete_data_for_user(self, *, requester, user_id): + """Delete a user's personal data. + + No personal data is stored in this cog. + """ + return + @commands.group() async def helpdamage(self, ctx): """Help info for the damage command @@ -238,7 +251,7 @@ async def helpdamage(self, ctx): Resistance, defense, loading by monster id, killers, etc coming soon """ - @commands.command() + @commands.command(aliases=['damagecalc']) async def damage(self, ctx, *, damage_spec): """Computes damage for the provided damage_spec diff --git a/damagecalc/info.json b/damagecalc/info.json new file mode 100644 index 00000000..0cad2163 --- /dev/null +++ b/damagecalc/info.json @@ -0,0 +1,25 @@ +{ + "author": [ + "The Tsubaki Bot Team" + ], + "description": "Calculate Puzzle & Dragons data", + "end_user_data_statement": "No personal data is stored.", + "install_msg": "Use [p]damage to get started.", + "short": "Calculates Puzzle & Dragons data", + "min_bot_version": "3.4.0", + "max_bot_version": "0.0.0", + "min_python_version": [ + 3, + 8, + 1 + ], + "hidden": false, + "disabled": false, + "required_cogs": {}, + "requirements": [ + "ply" + ], + "tags": [ + "pad" + ] +} diff --git a/dbcog/__init__.py b/dbcog/__init__.py new file mode 100644 index 00000000..eddb989f --- /dev/null +++ b/dbcog/__init__.py @@ -0,0 +1,13 @@ +import json +from pathlib import Path + +from .dbcog import DBCog + +with open(Path(__file__).parent / "info.json") as file: + __red_end_user_data_statement__ = json.load(file)['end_user_data_statement'] + + +async def setup(bot): + n = DBCog(bot) + bot.add_cog(n) if not __import__('asyncio').iscoroutinefunction(bot.add_cog) else await bot.add_cog(n) + n.bot.loop.create_task(n.reload_data_task()) diff --git a/dbcog/database_context.py b/dbcog/database_context.py new file mode 100644 index 00000000..529a7309 --- /dev/null +++ b/dbcog/database_context.py @@ -0,0 +1,94 @@ +from functools import lru_cache +from typing import Callable, Hashable, Iterable, List, Optional + +from tsutils.enums import Server + +from .database_manager import DBCogDatabase +from .dungeon_context import DungeonContext +from .models.awoken_skill_model import AwokenSkillModel +from .models.dungeon_model import DungeonModel +from .models.enum_types import DEFAULT_SERVER +from .models.monster_model import MonsterModel +from .models.scheduled_event_model import ScheduledEventModel +from .models.series_model import SeriesModel +from .monster_graph import MonsterGraph + +SCHEDULED_EVENT_QUERY = """SELECT + schedule.*, + dungeons.name_ja AS d_name_ja, + dungeons.name_en AS d_name_en, + dungeons.name_ko AS d_name_ko, + dungeons.dungeon_type AS dungeon_type +FROM + schedule + LEFT JOIN dungeons ON schedule.dungeon_id = dungeons.dungeon_id +""" + + +class DbContext: + def __init__(self, database: DBCogDatabase, graph: MonsterGraph, dungeon: DungeonContext, + debug_monster_ids: Optional[List[int]] = None): + self.database = database + self.graph = graph + self.dungeon = dungeon + + self.cached_filters = {} + self.debug_monster_ids = debug_monster_ids + + self.awoken_skill_map = {awsk.awoken_skill_id: awsk for awsk in self.get_all_awoken_skills()} + self.series_map = {series.series_id: series for series in self.get_all_series()} + + def get_monsters_where(self, f: Callable[[MonsterModel], bool], *, server: Server, cache_key: Hashable = None) \ + -> List[MonsterModel]: + if cache_key is None: + return [m for m in self.get_all_monsters(server) if f(m)] + + if (cache_key, server) in self.cached_filters: + return self.cached_filters[(cache_key, server)] + + self.cached_filters[(cache_key, server)] = self.get_monsters_where(f, server=server) + return self.cached_filters[(cache_key, server)] + + def get_monsters_by_series(self, series_id: int, *, server: Server) -> List[MonsterModel]: + return self.get_monsters_where(lambda m: m.series_id == series_id, server=server) + + def get_monsters_by_active(self, active_skill_id: int, *, server: Server) -> List[MonsterModel]: + return self.get_monsters_where(lambda m: m.active_skill_id == active_skill_id, server=server) + + def get_monsters_by_leader(self, leader_skill_id: int, *, server: Server) -> List[MonsterModel]: + return self.get_monsters_where(lambda m: m.leader_skill_id == leader_skill_id, server=server) + + def get_all_monster_ids(self, server: Server) -> Iterable[int]: + # We don't need to query if we're in debug mode. We already know exactly which monsters we're working with + if self.debug_monster_ids is not None: + return self.debug_monster_ids + + suffix = '_na' if server == Server.NA else '' + query = self.database.query_many(f"SELECT monster_id FROM monsters{suffix}", as_generator=True) + return (m.monster_id for m in query) + + def get_all_monsters(self, server: Server = DEFAULT_SERVER) -> List[MonsterModel]: + return [self.graph.get_monster(mid, server=server) for mid in self.get_all_monster_ids(server)] + + def get_all_awoken_skills(self) -> List[AwokenSkillModel]: + result = self.database.query_many("SELECT * FROM awoken_skills") + return [AwokenSkillModel(**r) for r in result] + + def get_all_series(self) -> List[SeriesModel]: + result = self.database.query_many("SELECT * FROM series") + return [SeriesModel(**r) for r in result] + + def get_all_events(self) -> Iterable[ScheduledEventModel]: + result = self.database.query_many(SCHEDULED_EVENT_QUERY) + for se in result: + se['dungeon_model'] = DungeonModel(name_ja=se['d_name_ja'], + name_en=se['d_name_en'], + name_ko=se['d_name_ko'], + **se) + yield ScheduledEventModel(**se) + + def has_database(self) -> bool: + return self.database.has_database() + + def close(self) -> None: + self.database.close() diff --git a/dbcog/database_loader.py b/dbcog/database_loader.py new file mode 100644 index 00000000..cc57cd28 --- /dev/null +++ b/dbcog/database_loader.py @@ -0,0 +1,30 @@ +import os +import shutil + +from redbot.core import data_manager + +from .database_context import DbContext +from .database_manager import DBCogDatabase +from .dungeon_context import DungeonContext +from .monster_graph import MonsterGraph + + +def _data_file(file_name: str) -> str: + return os.path.join(str(data_manager.cog_data_path(raw_name='dbcog')), file_name) + + +def load_database(existing_db, debug_monster_ids): + DB_DUMP_FILE = _data_file('dadguide.sqlite') + DB_DUMP_WORKING_FILE = _data_file('dadguide_working.sqlite') + # Release the handle to the database file if it has one + if existing_db: + existing_db.close() + # Overwrite the working copy so we can open a handle to it without affecting future downloads + if os.path.exists(DB_DUMP_FILE): + shutil.copy2(DB_DUMP_FILE, DB_DUMP_WORKING_FILE) + # Open the new working copy. + database = DBCogDatabase(data_file=DB_DUMP_WORKING_FILE) + graph = MonsterGraph(database, debug_monster_ids) + dungeon = DungeonContext(database) + db_context = DbContext(database, graph, dungeon, debug_monster_ids) + return db_context diff --git a/dbcog/database_manager.py b/dbcog/database_manager.py new file mode 100644 index 00000000..9b308390 --- /dev/null +++ b/dbcog/database_manager.py @@ -0,0 +1,98 @@ +import logging +import sqlite3 as lite +from typing import Any, Dict, Generator, List, Optional, Tuple, Type, TypeVar, Union + +logger = logging.getLogger('red.padbot-cogs.dbcog.database_manager') + +T = TypeVar('T') + + +class DictWithAttrAccess(Dict[str, T]): + def __init__(self, item: Optional[Dict[str, T]] = None, **items): + if items: + item = items + super(DictWithAttrAccess, self).__init__(item) + self.__dict__ = self + + +class DBCogDatabase: + def __init__(self, data_file: str): + self._con = lite.connect(data_file, detect_types=lite.PARSE_DECLTYPES) + self._con.row_factory = lite.Row + + def __del__(self): + self.close() + logger.info("Garbage Collecting Old Database") + + def has_database(self) -> bool: + return self._con is not None + + def close(self) -> None: + if self._con: + self._con.close() + self._con = None + + @staticmethod + def select_builder(tables, key: Optional[str] = None, where: Optional[str] = None, + order: Optional[str] = None, distinct: bool = False) -> str: + if distinct: + SELECT_FROM = 'SELECT DISTINCT {fields} FROM {first_table}' + else: + SELECT_FROM = 'SELECT {fields} FROM {first_table}' + WHERE = 'WHERE {condition}' + JOIN = 'LEFT JOIN {other_table} ON {first_table}.{key}={other_table}.{key}' + ORDER = 'ORDER BY {order}' + first_table = None + fields_lst = [] + other_tables = [] + for table, fields in tables.items(): + if fields is not None: + fields_lst.extend(['{}.{}'.format(table, f) for f in fields]) + if first_table is None: + first_table = table + if key is None: + break + else: + other_tables.append(table) + query = [SELECT_FROM.format(first_table=first_table, fields=', '.join(fields_lst))] + prev_table = first_table + if key: + for k, other in zip(key, other_tables): + query.append(JOIN.format(first_table=prev_table, other_table=other, key=k)) + prev_table = other + if where: + query.append(WHERE.format(condition=where)) + if order: + query.append(ORDER.format(order=order)) + return ' '.join(query) + + def query_one(self, query: str, param: Tuple = None) -> Optional[DictWithAttrAccess]: + if param is None: + param = () + + cursor = self._con.cursor() + cursor.execute(query, param) + res = cursor.fetchone() + if res is not None: + return DictWithAttrAccess(res) + return None + + def query_many(self, query: str, param: Tuple = None, idx_key: Optional[str] = None, + as_generator: bool = False, as_type: Type[T] = DictWithAttrAccess) \ + -> Union[Generator[T, None, None], + List[T], + Dict[Any, T]]: + if param is None: + param = () + + cursor = self._con.cursor() + cursor.execute(query, param) + if cursor.rowcount == 0: + return [] + if as_generator: + return (as_type(**res) for res in cursor.fetchall()) + else: + if idx_key is None: + return [as_type(**res) for res in cursor.fetchall()] + else: + return {res[idx_key]: as_type(**res) for res in cursor.fetchall()} diff --git a/dbcog/dbcog.py b/dbcog/dbcog.py new file mode 100644 index 00000000..8a50b39b --- /dev/null +++ b/dbcog/dbcog.py @@ -0,0 +1,436 @@ +""" +Provides access to DadGuide data. + +Access via the exported DadGuide sqlite database. + +Don't hold on to any of the dastructures exported from here, or the +entire database could be leaked when the module is reloaded. +""" +import asyncio +import logging +import os +import shutil +import time +from io import BytesIO +from typing import Any, Dict, List, Optional, Tuple, Union + +import discord +from redbot.core import Config, checks, commands, data_manager +from redbot.core.utils.chat_formatting import box, pagify, inline, text_to_file +from tabulate import tabulate +from tsutils.cogs.globaladmin import auth_check +from tsutils.enums import Server +from tsutils.json_utils import async_cached_dadguide_request +from tsutils.tsubaki.links import CLOUDFRONT_URL +from tsutils.user_interaction import StatusManager, send_confirmation_message + +from .find_monster import token_mappings +from .database_context import DbContext +from .database_loader import load_database +from dbcog.find_monster.find_monster import FindMonster, MonsterInfo +from .find_monster.extra_info import ExtraInfo +from dbcog.find_monster.idtest_mixin import IdTest +from .models.enum_types import DEFAULT_SERVER, SERVERS +from .models.monster_model import MonsterModel +from .models.monster_stats import MonsterStatModifierInput, monster_stats +from .monster_index import MonsterIndex +from dbcog.find_monster.token_mappings import MONSTER_ATTR_ALIAS_TO_ATTR_MAP, MONSTER_CLASS_ATTRIBUTES, AWOKEN_SKILL_MAP, \ + KNOWN_AWOKEN_SKILL_TOKENS + +logger = logging.getLogger('red.padbot-cogs.dbcog') + + +def _data_file(file_name: str) -> str: + return os.path.join(str(data_manager.cog_data_path(raw_name='dbcog')), file_name) + + +# TODO: Find a way to get literal back +class LiteralConverter: + def __class_getitem__(cls, *_): + return Any + + +class DBCog(commands.Cog, IdTest): + """Database manager""" + + def __init__(self, bot, *args, **kwargs): + super().__init__(*args, **kwargs) + self.bot = bot + self._is_ready = asyncio.Event() + + self.database: Optional[DbContext] = None + self.indexes: Dict[Server, MonsterIndex] = { + Server.COMBINED: MonsterIndex(Server.COMBINED), + Server.NA: MonsterIndex(Server.NA), + } + + self.fir_lock = asyncio.Lock() + self.fir3_lock = asyncio.Lock() + + self.fm_flags_default = {'na_prio': True, 'server': DEFAULT_SERVER, 'ormod_prio': False} + self.config = Config.get_conf(self, identifier=64667103) + self.config.register_global(datafile='', indexlog=0, test_suite={}, fluff_suite=[], typo_mods=[], + debug_mode=False, debug_mode_monsters=[3260]) + self.config.register_user(lastaction=None, fm_flags={}) + + self.db_file_path = _data_file('dadguide.sqlite') + self.monster_stats = monster_stats + self.MonsterStatModifierInput = MonsterStatModifierInput + + self.token_maps = token_mappings + self.DEFAULT_SERVER = DEFAULT_SERVER + self.SERVERS = SERVERS + + # expose for documentation command + self.AWOKEN_SKILL_TOKEN_MAP = AWOKEN_SKILL_MAP + self.KNOWN_AWOKEN_SKILL_TOKENS = KNOWN_AWOKEN_SKILL_TOKENS + + self.mon_finder = None # type: Optional[FindMonster] + + gadmin: Any = self.bot.get_cog("GlobalAdmin") + if gadmin: + gadmin.register_perm("contentadmin") + + async def wait_until_ready(self) -> None: + """Wait until the DBCog cog is ready. + + Call this from other cogs to wait until DBCog finishes refreshing its database + for the first time. + """ + await self._is_ready.wait() + + async def wait_until_ready_verbose(self, ctx) -> None: + """Wait until the DBCog cog is ready but loudly. + + Call this from other cogs to wait until DBCog finishes refreshing its database + for the first time. + """ + if self._is_ready.is_set(): + return + msg = await ctx.send("Index is still loading. Please wait...") + await self._is_ready.wait() + try: + await msg.delete() + except discord.Forbidden: + pass + + async def red_get_data_for_user(self, *, user_id): + """Get a user's personal data.""" + udata = await self.config.user_from_id(user_id).ans() + + data = "Your stored query flags are: " + "\n".join(udata['fm_flags'].keys()) + data += f"\nUse '{(await self.bot.get_valid_prefixes())[0]}idset list' to see what they're set to." + + if not udata: + data = "No data is stored for user with ID {}.\n".format(user_id) + + return {"user_data.txt": BytesIO(data.encode())} + + async def red_delete_data_for_user(self, *, requester, user_id): + """Delete a user's personal data.""" + await self.config.user_from_id(user_id).clear() + + @commands.command(aliases=['fir']) + @auth_check('contentadmin') + async def forceindexreload(self, ctx): + if self.fir_lock.locked(): + await ctx.send("Index is already being reloaded.") + return + + async with ctx.typing(), self.fir_lock: + start = time.perf_counter() + await ctx.send('Starting reload...') + await self.wait_until_ready() + await self.download_and_refresh_nicknames() + await ctx.send('Reload finished in {} seconds.'.format(round(time.perf_counter() - start, 2))) + + @commands.command(aliases=['fir3']) + @auth_check('contentadmin') + async def forceindexreload3(self, ctx): + if self.fir3_lock.locked(): + await ctx.send("Index is already being reloaded.") + return + + async with ctx.typing(), self.fir3_lock: + start = time.perf_counter() + await self.wait_until_ready() + await self.create_index() + await ctx.send('Reload finished in {} seconds.'.format(round(time.perf_counter() - start, 2))) + + async def create_index(self): + """Exported function that allows a client cog to create an id3 monster index""" + for server in SERVERS: + await self.indexes[server].reset(self.database.graph) + + self.mon_finder = FindMonster(self, self.fm_flags_default) + asyncio.create_task(self.check_index()) + + async def load_old_index(self): + data = b'\x80\x04}\x94.' + for server in SERVERS: + self.indexes[server] = MonsterIndex.deserialize(data, server, self.database.graph) + + self.mon_finder = FindMonster(self, self.fm_flags_default) + asyncio.create_task(self.check_index()) + + async def check_index(self): + issues = [] + issues.extend(self.database.graph.issues) + for index in self.indexes.values(): + issues.extend(index.issues) + for class_attributes in MONSTER_CLASS_ATTRIBUTES: + for monster in self.database.get_all_monsters(): + val: Any = monster + for ca in class_attributes: + if not hasattr(val, ca): + break + val = getattr(val, ca) + else: + break + else: + issues.append(f"Invalid class attribute: {'.'.join(class_attributes)}") + + issues.extend(await self.run_tests()) + + if issues and self.database.graph.debug_monster_ids is None: + channels = [self.bot.get_channel(await self.config.indexlog())] + if not any(channels): + channels = [owner for oid in self.bot.owner_ids if (owner := self.bot.get_user(oid))] + for channel in channels: + await channel.send("Use `{}dbcog setfailurechannel `" + " to move these out of your DMs!" + "".format((await self.bot.get_valid_prefixes())[0])) + for page in pagify(f"Load Warnings:\n" + "\n".join(issues[:100])): + for channel in channels: + await channel.send(box(page)) + + async def get_index(self, server: Server = DEFAULT_SERVER) -> MonsterIndex: + await self.indexes[server].is_ready.wait() + return self.indexes[server] + + def get_monster(self, monster_id: int, *, server: Server = DEFAULT_SERVER) -> Optional[MonsterModel]: + """Exported function that allows a client cog to get a full MonsterModel by monster_id""" + return self.database.graph.get_monster(monster_id, server=server) + + def cog_unload(self): + # Manually nulling out database because the GC for cogs seems to be pretty shitty + logger.info('Unloading DBCog') + if self.database: + self.database.close() + self.database = None + self._is_ready.clear() + + async def reload_data_task(self): + await self.bot.wait_until_ready() + + # We already had a copy of the database at startup, signal that we're ready now. + if self.database and self.database.has_database(): + logger.info('Using stored database at load') + + while self == self.bot.get_cog('DBCog'): + short_wait = False + try: + async with StatusManager(self.bot): + await self.download_and_refresh_nicknames() + logger.info('Done refreshing DBCog') + except Exception as ex: + short_wait = True + logger.exception("DBCog data download/refresh failed: %s", ex) + + try: + wait_time = 60 if short_wait else 60 * 60 * 4 + await asyncio.sleep(wait_time) + except Exception as ex: + logger.exception("DBCog data wait loop failed: %s", ex) + raise + + async def download_and_refresh_nicknames(self): + if await self.config.datafile(): + logger.info('Copying database file') + shutil.copy2(await self.config.datafile(), self.db_file_path) + else: + logger.info('Downloading database files') + await async_cached_dadguide_request(self.db_file_path, + CLOUDFRONT_URL + '/db/dadguide.sqlite', + 1 * 60 * 60) + + logger.info('Loading database') + self.database = load_database(self.database, await self.get_debug_monsters()) + logger.info('Building monster index, triggering ready') + self._is_ready.set() + await self.create_index() + + logger.info('Done refreshing database') + + @commands.group() + @checks.is_owner() + async def dbcog(self, ctx): + """DBCog database settings""" + + @dbcog.command() + @checks.is_owner() + async def setdatafile(self, ctx, data_file): + """Set a local path to dbcog data instead of downloading it.""" + await self.config.datafile.set(data_file) + await ctx.tick() + + @dbcog.command() + @checks.is_owner() + async def setfailurechannel(self, ctx, channel: discord.TextChannel): + await self.config.indexlog.set(channel.id) + await ctx.tick() + + @dbcog.group(aliases=["debug"]) + @checks.is_owner() + async def debugmode(self, ctx): + """Tsubaki-Only Mode settings""" + + @debugmode.command(name="enable", aliases=["enabled"]) + async def debug_enable(self, ctx, enabled: bool): + """Sets tsubaki-only mode and reloads the index and graph""" + await self.config.debug_mode.set(enabled) + if enabled: + await send_confirmation_message(ctx, f"You have set your bot to **Tsubaki mode** (aka debug mode)." + f" Only **Tsubaki** will be available in the graph and id" + f" queries, to allow for a faster restart time. To turn" + f" off **Tsubaki mode** run" + f" `{ctx.prefix}dbcog debugmode false`.") + else: + await send_confirmation_message(ctx, f"You have turned off **Tsubaki mode**. Your bot will behave" + f" normally. To return to **Tsubaki mode**, run" + f" `{ctx.prefix}dbcog debugmode true.`") + await self.forceindexreload(ctx) + + @debugmode.group(name="monsters") + async def debug_monsters(self, ctx): + """Monsters allowed in Tsubaki-Only mode""" + + @debug_monsters.command(name="add") + async def debug_monsters_add(self, ctx, *monsters: int): + async with self.config.debug_mode_monsters() as ts_monsters: + for monster in monsters: + if monster not in ts_monsters: + ts_monsters.append(monster) + if await self.config.debug_mode(): + await self.forceindexreload(ctx) + await ctx.tick() + + @debug_monsters.command(name="remove", aliases=["rm", "del", "delete"]) + async def debug_monsters_rm(self, ctx, *monsters: int): + async with self.config.debug_mode_monsters() as ts_monsters: + for monster in monsters: + if monster in ts_monsters: + ts_monsters.remove(monster) + if await self.config.debug_mode(): + await self.forceindexreload(ctx) + await ctx.tick() + + @debug_monsters.command(name="list") + async def debug_monsters_list(self, ctx): + text = "\n".join(f'{m} {mon.name_en}' if (mon := self.get_monster(m)) else f"Invalid monster {m}" + for m in await self.config.debug_mode_monsters()) + for page in pagify(text): + await ctx.send(box(page)) + + async def get_debug_monsters(self) -> Optional[List[int]]: + if await self.config.debug_mode(): + return await self.config.debug_mode_monsters() + return None + + async def get_fm_flags(self, author_id): + return {**self.fm_flags_default, **(await self.config.user_from_id(author_id).fm_flags())} + + async def find_monster(self, query: str, author_id: int = 0) -> Optional[MonsterModel]: + monster, e_info = await FindMonster(self, await self.get_fm_flags(author_id)).find_monster(query) + return monster + + async def find_monsters(self, query: str, author_id: int = 0) -> Tuple[List[MonsterModel], ExtraInfo]: + monsters, e_info = await FindMonster(self, await self.get_fm_flags(author_id)).find_monsters(query) + return monsters, e_info + + async def find_monster_debug(self, query: str) -> Tuple[MonsterInfo, ExtraInfo]: + return await FindMonster(self, self.fm_flags_default).find_monster_debug(query) + + @staticmethod + def get_aliased_attribute(monster: MonsterModel, alias: str) -> Union[Dict[str, Any], Any]: + if alias not in MONSTER_ATTR_ALIAS_TO_ATTR_MAP: + raise ValueError(f"Invalid alias {alias}") + + ret = {} + keys = MONSTER_ATTR_ALIAS_TO_ATTR_MAP[alias] + while all(len(key) > 1 for key in keys) \ + and len({key[0] for key in keys}) == 1: + keys = [key[1:] for key in keys] + while all(len(key) > 1 for key in keys) \ + and len({key[-1] for key in keys}) == 1: + keys = [key[:-1] for key in keys] + keys = [' - '.join(key) for key in keys] + for string, replacement in { + '_en': ' (english)', + '_ja': ' (japanese)', + '_ko': ' (korean)', + '_': ' ', + }.items(): + keys = [key.replace(string, replacement) for key in keys] + + for key, attrs in zip(keys, MONSTER_ATTR_ALIAS_TO_ATTR_MAP[alias]): + value = monster + for attr in attrs: + value = getattr(value, attr) + ret[key] = value + if len(ret) == 1: + return set(ret.values()).pop() + return ret + + @commands.command() + async def attr(self, ctx, attr, *, query): + await self.wait_until_ready() + monster = await self.find_monster(query, ctx.author.id) + if monster is None: + return await ctx.send("Sorry, we could not locate this monster") + try: + data = self.get_aliased_attribute(monster, attr) + except ValueError: + return await ctx.send("Sorry, this attribute is not recognized") + except AttributeError: + return await ctx.send("Sorry, {} doesn't have this attribute".format(monster.name_en)) + if not isinstance(data, dict): + return await ctx.send(data) + return await ctx.send('\n'.join([f"**{k}**: {v}" for k, v in data.items()])) + + @commands.command() + async def exportmodifiers(self, ctx, server: LiteralConverter["COMBINED", "NA"] = "COMBINED"): + server = Server(server) + maps = self.token_maps + awakenings = {a.awoken_skill_id: a for a in self.database.get_all_awoken_skills()} + series = {s.series_id: s for s in self.database.get_all_series()} + + ret = ("Jump to:\n\n" + "* [Types](#types)\n" + "* [Evolutions](#evolutions)\n" + "* [Misc](#misc)\n" + "* [Awakenings](#awakenings)\n" + "* [Series](#series)\n" + "* [Attributes](#attributes)\n\n\n\n") + + etable = [(k.value, ", ".join(map(inline, v))) for k, v in maps.EVO_MAP.items()] + ret += "\n\n### Evolutions\n\n" + tabulate(etable, headers=["Meaning", "Tokens"], tablefmt="github") + ttable = [(k.name, ", ".join(map(inline, v))) for k, v in maps.TYPE_MAP.items()] + ret += "\n\n### Types\n\n" + tabulate(ttable, headers=["Meaning", "Tokens"], tablefmt="github") + mtable = [(k.value, ", ".join(map(inline, v))) for k, v in maps.MISC_MAP.items()] + ret += "\n\n### Misc\n\n" + tabulate(mtable, headers=["Meaning", "Tokens"], tablefmt="github") + atable = [(awakenings[k.value].name_en, ", ".join(map(inline, v))) for k, v in maps.AWOKEN_SKILL_MAP.items()] + ret += "\n\n### Awakenings\n\n" + tabulate(atable, headers=["Meaning", "Tokens"], tablefmt="github") + stable = [(series[k].name_en, ", ".join(map(inline, v))) + for k, v in (await self.get_index(server)).series_id_to_pantheon_nickname.items()] + ret += "\n\n### Series\n\n" + tabulate(stable, headers=["Meaning", "Tokens"], tablefmt="github") + ctable = [(k.name.replace("Nil", "None"), ", ".join(map(inline, v))) for k, v in maps.COLOR_MAP.items()] + ctable += [("Sub " + k.name.replace("Nil", "None"), ", ".join(map(inline, v))) for k, v in + maps.SUB_COLOR_MAP.items()] + for k, v in maps.DUAL_COLOR_MAP.items(): + k0name = k[0].name.replace("Nil", "None") + k1name = k[1].name.replace("Nil", "None") + ctable.append((k0name + "/" + k1name, ", ".join(map(inline, v)))) + ret += "### Attributes\n\n" + tabulate(ctable, headers=["Meaning", "Tokens"], tablefmt="github") + + await ctx.send(file=text_to_file(ret, filename="table.md")) diff --git a/dbcog/dungeon_context.py b/dbcog/dungeon_context.py new file mode 100644 index 00000000..66a9cad5 --- /dev/null +++ b/dbcog/dungeon_context.py @@ -0,0 +1,432 @@ +from collections import defaultdict +from functools import lru_cache +from typing import Dict, Iterable, List, Mapping, Optional, Set + +from tsutils.enums import Server + +from dbcog.database_manager import DBCogDatabase +from dbcog.models.dungeon_model import DungeonModel +from dbcog.models.encounter_model import EncounterModel +from dbcog.models.enemy_data_model import EnemyDataModel +from dbcog.models.enemy_skill_model import EnemySkillModel +from dbcog.models.enum_types import DEFAULT_SERVER +from dbcog.models.monster_model import MonsterModel +from dbcog.models.sub_dungeon_model import SubDungeonModel + + +def format_with_suffix(text: str, server: Server) -> str: + suffix_map = { + Server.COMBINED: '', + Server.NA: '_na' + } + return text.format(suffix_map[server]) + + +NICKNAME_QUERY = ''' +SELECT + enemy_data{0}.behavior, + encounters.encounter_id, + encounters.dungeon_id, + encounters.sub_dungeon_id, + encounters.enemy_id, + encounters.monster_id, + encounters.stage, + encounters.amount, + encounters.turns, + encounters.level, + encounters.hp, + encounters.atk, + encounters.defense, + sub_dungeons{0}.name_ja AS sub_name_ja, + sub_dungeons{0}.name_en AS sub_name_en, + sub_dungeons{0}.name_ko AS sub_name_ko, + sub_dungeons{0}.technical, + dungeons{0}.name_ja, + dungeons{0}.name_en, + dungeons{0}.name_ko, + dungeons{0}.dungeon_type +FROM + encounters + LEFT OUTER JOIN dungeons on encounters.dungeon_id = dungeons{0}.dungeon_id + LEFT OUTER JOIN enemy_data{0} on encounters.enemy_id = enemy_data{0}.enemy_id + LEFT OUTER JOIN monsters{0} on encounters.monster_id = monsters{0}.monster_id + LEFT OUTER JOIN sub_dungeons on sub_dungeons{0}.sub_dungeon_id = encounters.sub_dungeon_id +WHERE + encounters.sub_dungeon_id = ? +ORDER BY + encounters.sub_dungeon_id, + encounters.stage +''' + +SUB_DUNGEON_QUERY = ''' +SELECT + sub_dungeons{0}.sub_dungeon_id, + sub_dungeons{0}.dungeon_id, + sub_dungeons{0}.name_ja, + sub_dungeons{0}.name_en, + sub_dungeons{0}.name_ko, + sub_dungeons{0}.technical +FROM + sub_dungeons{0} +WHERE + sub_dungeons{0}.dungeon_id = ? +''' + +DUNGEON_QUERY = ''' +SELECT + dungeons{0}.dungeon_id, + dungeons{0}.name_ja, + dungeons{0}.name_en, + dungeons{0}.name_ko, + dungeons{0}.dungeon_type +FROM + dungeons{0} +WHERE + dungeons{0}.name_en LIKE ? +''' + +ES_QUERY = ''' +SELECT + enemy_skills{0}.enemy_skill_id, + enemy_skills{0}.name_en, + enemy_skills{0}.desc_en, + enemy_skills{0}.desc_en_emoji, + enemy_skills{0}.min_hits, + enemy_skills{0}.max_hits, + enemy_skills{0}.atk_mult +FROM + enemy_skills{0} +WHERE + enemy_skills{0}.enemy_skill_id = ? +''' + +SUB_DUNGEONS_QUERY_BY_NAME = ''' +SELECT + * +FROM + sub_dungeons{0} +WHERE + sub_dungeons{0}.dungeon_id = ? AND + sub_dungeons{0}.name_en LIKE ? +ORDER BY + sub_dungeons{0}.sub_dungeon_id +''' + +SUB_DUNGEON_QUERY_BY_INDEX = ''' +SELECT + * +FROM + sub_dungeons{0} +WHERE + sub_dungeons{0}.sub_dungeon_id = ? +ORDER BY + sub_dungeons{0}.sub_dungeon_id +''' + +ENCOUNTER_QUERY = ''' +SELECT + encounters.encounter_id, + encounters.sub_dungeon_id, + encounters.enemy_id, + encounters.monster_id, + encounters.stage, + encounters.amount, + encounters.turns, + encounters.level, + encounters.hp, + encounters.atk, + encounters.defense +FROM + encounters +WHERE + encounters.sub_dungeon_id = ? +ORDER BY + encounters.stage +''' + +SPECIFIC_FLOOR_QUERY = ''' +SELECT + encounters.encounter_id, + encounters.sub_dungeon_id, + encounters.enemy_id, + encounters.monster_id, + encounters.stage, + encounters.amount, + encounters.turns, + encounters.level, + encounters.hp, + encounters.atk, + encounters.defense +FROM + encounters +WHERE + encounters.sub_dungeon_id = ? +AND + encounters.stage = ? +''' + +ENEMY_DATA_QUERY = ''' +SELECT + enemy_data{0}.enemy_id, + enemy_data{0}.behavior +FROM + enemy_data{0} +WHERE + enemy_data{0}.enemy_id = ? +''' + +MONSTER_DROP_QUERY = ''' +SELECT + dungeons{0}.dungeon_id, + sub_dungeons{0}.sub_dungeon_id +FROM + drops + JOIN monsters{0} ON drops.monster_id = monsters{0}.monster_id + JOIN encounters ON drops.encounter_id = encounters.encounter_id + JOIN sub_dungeons{0} ON encounters.sub_dungeon_id = sub_dungeons{0}.sub_dungeon_id + JOIN dungeons{0} ON sub_dungeons{0}.dungeon_id = dungeons{0}.dungeon_id +WHERE + monsters{0}.monster_id = ? +''' + +SCHEDULED_EVENT_QUERY = """SELECT + schedule.*, + dungeons.name_ja AS d_name_ja, + dungeons.name_en AS d_name_en, + dungeons.name_ko AS d_name_ko, + dungeons.dungeon_type AS dungeon_type +FROM + schedule + LEFT JOIN dungeons ON schedule.dungeon_id = dungeons.dungeon_id +""" + +DROP_QUERY = """SELECT + sub_dungeons{0}.sub_dungeon_id +FROM + sub_dungeons{0} + JOIN encounters ON encounters.sub_dungeon_id = sub_dungeons{0}.sub_dungeon_id + JOIN drops ON drops.encounter_id = encounters.encounter_id +WHERE + drops.monster_id = ? +""" + +# TODO: Move to gdoc +DUNGEON_NICKNAMES = { + 'a1': 1022001, + 'arena1': 1022001, + 'bipolar goddess 1': 1022001, + 'bp1': 1022001, + 'a2': 1022002, + 'arena2': 1022002, + 'bipolar goddess 2': 1022002, + 'bp2': 1022002, + 'a3': 1022003, + 'arena3': 1022003, + 'bipolar goddess 3': 1022003, + 'bp3': 1022003, + 'a4': 1022004, + 'arena4': 1022004, + 'three hands of fate': 1022004, + 'thof': 1022004, + 'a5': 1022005, + 'arena5': 1022005, + 'incarnation of worlds': 1022006, + 'iow': 1022006, + 'aa1': 2660001, + 'aa2': 2660002, + 'aa3': 2660003, + 'aa4': 2660004, + 'shura1': 4400001, + 'shura2': 4400002, + 'shura3': 4400003, + 'iwoc': 4400001, + 'alt. iwoc': 4401001, +} + + +class DungeonContext(object): + def __init__(self, database: DBCogDatabase): + self._enemy_data_map: Dict[Server, Dict[int, EnemyDataModel]] = {} + self._encounter_map: Dict[Server, Dict[int, EncounterModel]] = {} + self._encounters_by_sub_dungeon_id: Dict[Server, Mapping[int, Set[EncounterModel]]] = {} + self._sub_dungeon_map: Dict[Server, Dict[int, SubDungeonModel]] = {} + self._sub_dungeons_by_dungeon_id: Dict[Server, Mapping[int, Set[SubDungeonModel]]] = {} + self._dungeon_map: Dict[Server, Dict[int, DungeonModel]] = {} + + self.database = database + + def get_dungeons_from_name(self, name: str, *, server: Server) -> List[DungeonModel]: + dungeons_result = self.database.query_many(format_with_suffix(DUNGEON_QUERY, server), (name + "%",)) + dungeons = [] + for d in dungeons_result: + dungeons.append(DungeonModel([], **d)) + + for dm in dungeons: + subs = self.database.query_many(format_with_suffix(SUB_DUNGEON_QUERY, server), (dm.dungeon_id,)) + for s in subs: + encounters = self.database.query_many(format_with_suffix(ENCOUNTER_QUERY, server), + (s['sub_dungeon_id'],)) + ems = [] + for e in encounters: + data = self.database.query_one(format_with_suffix(ENEMY_DATA_QUERY, server), (e["enemy_id"],)) + if data is not None: + edm = EnemyDataModel(**data) + else: + edm = None + ems.append(EncounterModel(edm, **e)) + dm.sub_dungeons.append(SubDungeonModel(ems, **s)) + return dungeons + + def get_dungeons_from_nickname(self, name: str, *, server: Server) -> List[DungeonModel]: + if name not in DUNGEON_NICKNAMES: + return [] + sub_id = DUNGEON_NICKNAMES.get(name) + mega = self.database.query_many(format_with_suffix(NICKNAME_QUERY, server), (sub_id,)) + ems = [] + for enc in mega: + data = self.database.query_one(format_with_suffix(ENEMY_DATA_QUERY, server), (enc["enemy_id"],)) + if data is not None: + edm = EnemyDataModel(**data) + else: + edm = None + ems.append(EncounterModel(edm, **enc)) + sm = SubDungeonModel(ems, + sub_dungeon_id=mega[0]['sub_dungeon_id'], + dungeon_id=mega[0]['dungeon_id'], + name_ja=mega[0]['sub_name_ja'], + name_en=mega[0]['sub_name_en'], + name_ko=mega[0]['sub_name_ko'], + technical=mega[0]['technical']) + return [DungeonModel([sm], **mega[0])] + + def get_floor_from_sub_dungeon(self, sub_id: int, floor: int, *, server: Server) -> List[EncounterModel]: + floor_query = self.database.query_many(format_with_suffix(SPECIFIC_FLOOR_QUERY, server), (sub_id, floor)) + invade_query = self.database.query_many(format_with_suffix(SPECIFIC_FLOOR_QUERY, server), (sub_id, -1)) + encounter_models = [] + floor_query.extend(invade_query) + for f in floor_query: + data = self.database.query_one(format_with_suffix(ENEMY_DATA_QUERY, server), (f['enemy_id'],)) + if data is not None: + edm = EnemyDataModel(**data) + else: + edm = None + encounter_models.append(EncounterModel(edm, **f)) + return encounter_models + + def get_enemy_skill(self, enemy_skill_id: int, *, server: Server) -> EnemySkillModel: + enemy_skill_query = self.database.query_one(format_with_suffix(ES_QUERY, server), (enemy_skill_id,)) + return EnemySkillModel(**enemy_skill_query) + + def get_sub_dungeon_id_from_name(self, dungeon_id: int, sub_dungeon_name: Optional[str], *, server: Server = DEFAULT_SERVER) \ + -> Optional[int]: + if sub_dungeon_name is None: + sub_dungeon_name = "" + if sub_dungeon_name.isdigit(): + sub_dungeons = self.database.query_many(format_with_suffix(SUB_DUNGEON_QUERY_BY_INDEX, server), + (dungeon_id * 1000 + int(sub_dungeon_name),)) + else: + sub_dungeons = self.database.query_many(format_with_suffix(SUB_DUNGEONS_QUERY_BY_NAME, server), + (dungeon_id, f"%{sub_dungeon_name}%")) + if len(sub_dungeons) == 0: + return None + elif len(sub_dungeons) > 1: + if 'plus' in sub_dungeon_name.lower(): + for sd in sub_dungeons: + if 'plus' in sd['name_en'].lower(): + return sd['sub_dungeon_id'] + else: + for sd in sub_dungeons: + if 'plus' not in sd['name_en'].lower(): + return sd['sub_dungeon_id'] + elif len(sub_dungeons) > 2: + return 1 + return sub_dungeons[0]['sub_dungeon_id'] + + @lru_cache(maxsize=None) + def get_all_enemy_data(self, *, server: Server = DEFAULT_SERVER) -> List[EnemyDataModel]: + suffix = "" + if server == Server.NA: + pass + # TODO: There's currently no servers for enemy data. + # suffix = '_na' + + result = self.database.query_many(f"SELECT * FROM enemy_data{suffix}") + return [EnemyDataModel(**r) for r in result] + + def get_enemy_data(self, enemy_id: int, *, server: Server = DEFAULT_SERVER) -> Optional[EnemyDataModel]: + if server not in self._enemy_data_map: + self._enemy_data_map[server] = {ed.enemy_id: ed for ed in self.get_all_enemy_data(server=server)} + return self._enemy_data_map[server].get(enemy_id) + + @lru_cache(maxsize=None) + def get_all_encounters(self, server: Server = DEFAULT_SERVER) -> List[EncounterModel]: + suffix = "" + if server == Server.NA: + pass + # TODO: There's currently no servers for encounters. + # suffix = '_na' + + result = self.database.query_many(f"SELECT * FROM encounters{suffix}") + return [EncounterModel(self.get_enemy_data(r.enemy_id), **r) for r in result] + + def get_encounter(self, encounter_id: int, *, server: Server = DEFAULT_SERVER) -> Optional[EncounterModel]: + if server not in self._encounter_map: + self._encounter_map[server] = {e.encounter_id: e for e in self.get_all_encounters(server=server)} + return self._encounter_map[server].get(encounter_id) + + @lru_cache(maxsize=None) + def get_all_sub_dungeons(self, server: Server = DEFAULT_SERVER) -> List[SubDungeonModel]: + if server not in self._encounters_by_sub_dungeon_id: + self._encounters_by_sub_dungeon_id[server] = defaultdict(set) + for encounter in self.get_all_encounters(server=server): + self._encounters_by_sub_dungeon_id[server][encounter.sub_dungeon_id].add(encounter) + + suffix = "" + if server == Server.NA: + suffix = '_na' + + result = self.database.query_many(f"SELECT * FROM sub_dungeons{suffix}") + return [SubDungeonModel(sorted(self._encounters_by_sub_dungeon_id[server][r.sub_dungeon_id], + key=lambda e: e.encounter_id), + **r) for r in result] + + def get_sub_dungeon(self, sub_dungeon_id: int, *, server: Server = DEFAULT_SERVER) -> Optional[SubDungeonModel]: + if server not in self._sub_dungeon_map: + self._sub_dungeon_map[server] = {e.sub_dungeon_id: e for e in self.get_all_sub_dungeons(server=server)} + return self._sub_dungeon_map[server].get(sub_dungeon_id) + + @lru_cache(maxsize=None) + def get_all_dungeons(self, server: Server = DEFAULT_SERVER) -> List[DungeonModel]: + if server not in self._sub_dungeons_by_dungeon_id: + self._sub_dungeons_by_dungeon_id[server] = defaultdict(set) + for sub_dungeon in self.get_all_sub_dungeons(server=server): + self._sub_dungeons_by_dungeon_id[server][sub_dungeon.dungeon_id].add(sub_dungeon) + + suffix = "" + if server == Server.NA: + suffix = '_na' + + result = self.database.query_many(f"SELECT * FROM dungeons{suffix}") + return [DungeonModel(sorted(self._sub_dungeons_by_dungeon_id[server][r.dungeon_id], + key=lambda sd: sd.sub_dungeon_id), + **r) for r in result] + + def get_dungeon(self, dungeon_id: int, *, server: Server = DEFAULT_SERVER) -> Optional[DungeonModel]: + if server not in self._dungeon_map: + self._dungeon_map[server] = {e.dungeon_id: e for e in self.get_all_dungeons(server=server)} + return self._dungeon_map[server].get(dungeon_id) + + @lru_cache(maxsize=None) + def get_subdungeons_from_drop_monster(self, monster: MonsterModel) -> List[SubDungeonModel]: + suffix = "" + if monster.server_priority == Server.NA: + suffix = '_na' + + rows = self.database.query_many(DROP_QUERY.format(suffix), (monster.monster_id,)) + return [self.get_sub_dungeon(row.sub_dungeon_id, server=monster.server_priority) for row in rows] + + def get_dungeon_mapping(self, subdungeons: Iterable[SubDungeonModel]) -> Mapping[DungeonModel, List[SubDungeonModel]]: + mapping = defaultdict(list) + for subdungeon in sorted(subdungeons, key=lambda sd: sd.sub_dungeon_id): + mapping[self.get_dungeon(subdungeon.dungeon_id)].append(subdungeon) + return mapping diff --git a/dbcog/errors.py b/dbcog/errors.py new file mode 100644 index 00000000..e91d79b9 --- /dev/null +++ b/dbcog/errors.py @@ -0,0 +1,10 @@ +class InvalidGraphState(KeyError): + pass + + +class InvalidMonsterId(KeyError): + def __init__(self, monster_id: int): + self.monster_id = monster_id + + def __str__(self): + return '{} is not found in the graph'.format(str(self.monster_id)) diff --git a/dbcog/find_monster/__init__.py b/dbcog/find_monster/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dbcog/find_monster/extra_info.py b/dbcog/find_monster/extra_info.py new file mode 100644 index 00000000..d22c5a84 --- /dev/null +++ b/dbcog/find_monster/extra_info.py @@ -0,0 +1,72 @@ +from collections import defaultdict +from typing import NamedTuple, Mapping, Set + +from frozendict import frozendict + +from dbcog.find_monster.tokens.find_monster_tokens import MatchMap +from dbcog.models.monster_model import MonsterModel + + +class SubqueryData(NamedTuple): + # Subquery token type matched + label: str + + # Subquery given + subquery: str + + # Immutable Parent-to-Child monster mapping + map: Mapping[int, int] + + +class SubqueryMonsters(NamedTuple): + map: Mapping[int, MonsterModel] + + +class ExtraInfo(NamedTuple): + # Subquery Result + subquery_data: Set[SubqueryData] + + subquery_monsters: Set[SubqueryMonsters] + + # Unused + return_code: int + + def get_subquery_mon(self, matched_mon_id: int) -> int: + """Returns the subquery monster ID, preferring the one that restricted the query by the most (i.e. returned the fewest options in the result set)""" + best_weight = 100000000000 # infinity + subquery_mon = None + item: SubqueryData + for item in list(self.subquery_data): + if matched_mon_id not in item.map: + continue + # we want to show the most restrictive query possible + if len(item.map) <= best_weight: + subquery_mon = item.map[matched_mon_id] + if subquery_mon is None: + raise KeyError + return subquery_mon + + def get_monster(self, m_id) -> MonsterModel: + for item in list(self.subquery_monsters): + if m_id in item.map: + return item.map[m_id] + raise KeyError + + @staticmethod + def build_extra_info(monster_matches: MatchMap) -> "ExtraInfo": + subquery_data = defaultdict(dict) + subquery_mons = defaultdict() + for monster, match in monster_matches.items(): + for mod_token in match.mod: + match_data = mod_token.match_data + if match_data.subquery_result: + subquery_data[match_data.token][monster.monster_id] = match_data.subquery_result.monster_id + if match_data.subquery_result.monster_id not in subquery_mons: + subquery_mons[match_data.subquery_result.monster_id] = match_data.subquery_result + subquery_data = {SubqueryData(token.label, token.subquery, frozendict(m)) + for token, m in subquery_data.items()} + + subquery_mons = {SubqueryMonsters(frozendict({token: mon})) + for token, mon in subquery_mons.items()} + + return ExtraInfo(subquery_data, subquery_mons, 0) diff --git a/dbcog/find_monster/find_monster.py b/dbcog/find_monster/find_monster.py new file mode 100644 index 00000000..873fe3ef --- /dev/null +++ b/dbcog/find_monster/find_monster.py @@ -0,0 +1,324 @@ +import re +from collections import defaultdict +from typing import Any, DefaultDict, Dict, Iterable, List, Optional, Set, Tuple, Union + +from tsutils.enums import Server +from tsutils.formatting import rmdiacritics +from tsutils.query_settings.query_settings import QuerySettings + +from dbcog.find_monster.extra_info import ExtraInfo +from dbcog.find_monster.tokens.find_monster_tokens import EPSILON, MODIFIER_JW_DISTANCE, MatchData, MatchMap, \ + MonsterInfo, MonsterMatch, QueryToken, SpecialToken, \ + TOKEN_JW_DISTANCE, TokenMatch, calc_ratio_modifier, calc_ratio_name, string_to_token +from dbcog.models.monster_model import MonsterModel + +SERIES_TYPE_PRIORITY = { + "regular": 4, + "event": 4, + "seasonal": 3, + "ghcollab": 2, + "collab": 1, + "lowpriority": 0, + None: 0 +} + + +class FindMonster: + TOKEN_JW_DISTANCE = TOKEN_JW_DISTANCE + MODIFIER_JW_DISTANCE = MODIFIER_JW_DISTANCE + + def __init__(self, dbcog, flags: Dict[str, Any]): + self.dbcog = dbcog + self.flags = flags + self.index = self.dbcog.indexes[Server(flags['server'])] + + async def _process_settings(self, original_query: str) -> str: + query_settings = QuerySettings.extract(self.flags, original_query) + + self.index = await self.dbcog.get_index(query_settings.server) + + return re.sub(r'\s*(--|—)\w+(:{.+?})?(?:\s+|$)', ' ', original_query) + + def _merge_multi_word_tokens(self, tokens: List[str]) -> List[str]: + result = [] + skip = 0 + + multi_word_tokens_sorted = sorted(self.index.multi_word_tokens, + key=lambda x: (len(x), len(''.join(x))), + reverse=True) + for c1, token1 in enumerate(tokens): + if skip: + skip -= 1 + continue + for mwt in multi_word_tokens_sorted: + if len(mwt) > len(tokens) - c1: + continue + for c2, token2 in enumerate(mwt): + if (tokens[c1 + c2] != token2 and len(token2) < 5) \ + or self.calc_ratio_modifier(tokens[c1 + c2], token2) < TOKEN_JW_DISTANCE: + break + else: + skip = len(mwt) - 1 + result.append("".join(tokens[c1:c1 + len(mwt)])) + break + else: + result.append(token1) + return result + + async def _monster_has_modifier(self, monster: MonsterModel, token: QueryToken, matches: MatchMap) -> bool: + score, match = await token.matches(monster, self.index) + if not score: + return False ^ token.negated + + matches[monster].score += score + matches[monster].mod.add(match) + return True ^ (token.negated and match.match_data.can_negate) + + async def _interpret_query(self, tokenized_query: List[str]) -> Tuple[Set[QueryToken], Set[QueryToken]]: + modifiers = [] + name = set() + longmods = [p for p in self.index.all_modifiers if len(p) > 8] + lastmodpos = False + + # Suffixes + for i, value in enumerate(tokenized_query[::-1]): + token = await string_to_token(value, self.dbcog) + + if any(self.calc_ratio_modifier(m, token.value.split('-')[0], .1) > MODIFIER_JW_DISTANCE + for m in self.index.suffixes) or isinstance(token, SpecialToken): + # TODO: Store this as a list of regexes. Don't split on '-' anymore. + modifiers.append(token) + else: + if i != 0: + tokenized_query = tokenized_query[:-i] + break + + # Prefixes + for i, value in enumerate(tokenized_query): + token = await string_to_token(value, self.dbcog) + + if isinstance(token, SpecialToken) or token.value in self.index.all_modifiers \ + or (any(self.calc_ratio_modifier(token, m, .1) > MODIFIER_JW_DISTANCE for m in longmods) + and token.value not in self.index.all_name_tokens + and len(token.value) >= 8): + lastmodpos = not token.negated + modifiers.append(token) + else: + tokenized_query = tokenized_query[i:] + break + else: + tokenized_query = [] + + # Name Tokens + for value in tokenized_query: + name.add(await string_to_token(value, self.dbcog)) + + # Allow modifiers to match as name tokens if they're alone (Fix for hel and cloud) + if not name and modifiers and lastmodpos: + if self.index.manual[modifiers[-1].value]: + name.add(modifiers[-1]) + modifiers = modifiers[:-1] + + return set(modifiers), name + + async def _process_name_tokens(self, name_query_tokens: Set[QueryToken], matches: MatchMap) \ + -> Optional[Set[MonsterModel]]: + matched_mons = None + + for name_token in name_query_tokens: + if name_token.negated: + invalid = await self._get_valid_monsters_from_name_token(name_token, matches, mult=-10) + if matched_mons is not None: + matched_mons.difference_update(invalid) + else: + matched_mons = set(self.dbcog.database.get_all_monsters(self.index.server)).difference(invalid) + else: + valid = await self._get_valid_monsters_from_name_token(name_token, matches) + if matched_mons is not None: + matched_mons.intersection_update(valid) + else: + matched_mons = valid + + return matched_mons + + async def _get_valid_monsters_from_name_token(self, token: QueryToken, matches: MatchMap, + mult: Union[int, float] = 1) -> Set[MonsterModel]: + valid_monsters = set() + all_monsters_name_tokens_scores = {nt: self.calc_ratio_name(token, nt) for nt in + self.index.all_name_tokens} + matched_tokens = sorted((nt for nt, s in all_monsters_name_tokens_scores.items() if s > TOKEN_JW_DISTANCE), + key=lambda nt: all_monsters_name_tokens_scores[nt], reverse=True) + matched_tokens += [t for t in self.index.all_name_tokens if t.startswith(token.value)] + for match in matched_tokens: + score = all_monsters_name_tokens_scores[match] + + async def do_matching(monsters: Set[MonsterModel], value: float, name_type: str) -> None: + for matched_monster in monsters: + if matched_monster not in valid_monsters and await token.matches(matched_monster, self.index): + matches[matched_monster].name.add( + TokenMatch(token.value, match, MatchData(token, name_type=name_type))) + matches[matched_monster].score += value + valid_monsters.add(matched_monster) + + await do_matching(self.index.manual[match], (score + EPSILON) * mult, 'manual') + await do_matching(self.index.name_tokens[match], score * mult, 'name') + await do_matching(self.index.fluff_tokens[match], score * mult / 2, 'fluff') + + return valid_monsters + + async def _process_modifiers(self, mod_tokens: Set[QueryToken], potential_evos: Set[MonsterModel], + matches: MatchMap) -> Set[MonsterModel]: + for mod_token in mod_tokens: + potential_evos = {m for m in potential_evos if + await self._monster_has_modifier(m, mod_token, matches)} + if not potential_evos: + return set() + return potential_evos + + def get_priority_tuple(self, monster: MonsterModel, tokenized_query: List[str] = None, matches: MatchMap = None) \ + -> Tuple[Any, ...]: + """Get the priority tuple for a monster. + + This is a comparable tuple that's used to sort how well a monster matches a query. + """ + if matches is None: + matches = defaultdict(MonsterMatch) + if tokenized_query is None: + tokenized_query = [] + + or_prio_score = sum(match.match_data.or_index[0] + for match in matches[monster].mod + if match.match_data.or_index is not None) + + return (matches[monster].score, + -or_prio_score if self.flags['ormod_prio'] else 0, + not self.dbcog.database.graph.monster_is_evo_gem(monster), + # Don't deprio evos with new modifier + not monster.is_equip if not {m[0] for m in matches[monster].mod}.intersection( + {'new', 'base'}) else True, + # Match na on id overlap + bool(monster.monster_id > 10000 and re.search(r"\d{4}", " ".join(tokenized_query))), + SERIES_TYPE_PRIORITY.get(monster.series.series_type), + monster.on_na if monster.series.series_type == "collab" else True, + self.dbcog.database.graph.monster_is_rem_evo(monster), + not all(t.value in [0, 12, 14, 15] for t in monster.types), + not any(t.value in [0, 12, 14, 15] for t in monster.types), + -self.dbcog.database.graph.get_base_id(monster), + monster.on_na if self.flags['na_prio'] else True, + not monster.is_equip, + self.dbcog.database.graph.get_adjusted_rarity(monster), + -or_prio_score, + monster.monster_no_na) + + def _get_monster_evos(self, matched_mons: Set[MonsterModel], matches: MatchMap) -> Set[MonsterModel]: + monster_evos = set() + for monster in sorted(matched_mons, key=lambda m: matches[m].score, reverse=True): + for evo in self.dbcog.database.graph.get_alt_monsters(monster): + monster_evos.add(evo) + if matches[evo].score < matches[monster].score: + matches[evo].name = { + TokenMatch(t[0], t[1], MatchData(t.match_data.token, from_evo=monster.monster_id)) + for t in matches[monster].name} + matches[evo].score = matches[monster].score - EPSILON * 3 + + return monster_evos + + def get_most_eligable_monster(self, monsters: Iterable[MonsterModel], tokenized_query: List[str] = None, + matches: MatchMap = None) -> MonsterModel: + """Get the most eligable monster from a list of monsters and debug info from a query""" + if matches is None: + matches = defaultdict(MonsterMatch) + if tokenized_query is None: + tokenized_query = [] + + return max(monsters, key=lambda m: self.get_priority_tuple(m, tokenized_query, matches)) + + async def _find_monster_search(self, tokenized_query: List[str]) -> \ + Tuple[Optional[MonsterModel], MatchMap, Set[MonsterModel]]: + mod_tokens, name_query_tokens = await self._interpret_query(tokenized_query) + + name_query_tokens.difference_update({'|'}) + + def merge_dicts(*dicts: Dict[str, Set]) -> DefaultDict[str, Set]: + o = defaultdict(set) + for d in dicts: + for k, vs in d.items(): + o[k].update(vs) + return o + + for mod_token in mod_tokens: + if mod_token.value not in self.index.all_modifiers: + async with self.dbcog.config.typo_mods() as typo_mods: + typo_mods.append(mod_token.value) + + matches = defaultdict(MonsterMatch) + if name_query_tokens: + matched_mons = await self._process_name_tokens(name_query_tokens, matches) + if not matched_mons: + # No monsters match the given name tokens + return None, {}, set() + matched_mons = self._get_monster_evos(matched_mons, matches) + else: + # There are no name tokens in the query + matched_mons = {*self.dbcog.database.get_all_monsters(self.index.server)} + monster_score = defaultdict(int) + + # Expand search to the evo tree + matched_mons = await self._process_modifiers(mod_tokens, matched_mons, matches) + if not matched_mons: + # no modifiers match any monster in the evo tree + return None, {}, set() + + # Return most likely candidate based on query. + mon = self.get_most_eligable_monster(matched_mons, tokenized_query, matches) + + return mon, matches, matched_mons + + async def find_monster_debug(self, query: str) -> Tuple[MonsterInfo, ExtraInfo]: + """Get debug info from a search. + + This gives info that isn't necessary for non-debug functions. Consider using + findmonster or findmonsters instead.""" + await self.dbcog.wait_until_ready() + + query = query.split('//')[0] # Remove comments + query = rmdiacritics(query).lower().replace(",", "") + query = re.sub(r'(\s|^)\'(\S+)\'(\s|$)', r'\1"\2"\3', query) # Replace ' with " around tokens + # Keep some spaces + query = re.sub(r':=?r?("[^"]+"|\'[^\']+\')', lambda m: m.group(0).replace(' ', '\0'), query) + query = re.sub(r'\[[^[]+]', lambda m: m.group(0).replace(' ', '\0'), query) + query = re.sub(r'\([^(]+\)', lambda m: m.group(0).replace(' ', '\0'), query) + + tokenized_query = (await self._process_settings(query)).split() + tokenized_query = [token.replace('\0', ' ') for token in tokenized_query] + mw_tokenized_query = self._merge_multi_word_tokens(tokenized_query) + best_monster, matches_dict, valid_monsters = max( + await self._find_monster_search(tokenized_query), + await self._find_monster_search(mw_tokenized_query) + if tokenized_query != mw_tokenized_query else (None, {}, set()), + + key=lambda t: t[1].get(t[0], MonsterMatch()).score + ) + + monster_info = MonsterInfo(best_monster, matches_dict, valid_monsters) + extra_info = ExtraInfo.build_extra_info(matches_dict) + return monster_info, extra_info + + async def find_monster(self, query: str) -> Tuple[Optional[MonsterModel], ExtraInfo]: + """Get the best matching monster for a query. Returns None if no eligable monsters exist.""" + m_info, e_info = await self.find_monster_debug(query) + return m_info.matched_monster, e_info + + async def find_monsters(self, query: str) -> Tuple[List[MonsterModel], ExtraInfo]: + """Get a list of monsters sorted by how well they match""" + m_info, e_info = await self.find_monster_debug(query) + return sorted(m_info.valid_monsters, + key=lambda m: self.get_priority_tuple(m, matches=m_info.monster_matches), + reverse=True), e_info + + def calc_ratio_modifier(self, s1: Union[QueryToken, str], s2: str, prefix_weight: float = .05) -> float: + return calc_ratio_modifier(s1, s2, prefix_weight) + + def calc_ratio_name(self, token: Union[QueryToken, str], full_word: str, prefix_weight: float = .05) -> float: + """Calculate the name distance between two tokens""" + return calc_ratio_name(token, full_word, prefix_weight, self.index) diff --git a/dbcog/find_monster/idtest_mixin.py b/dbcog/find_monster/idtest_mixin.py new file mode 100644 index 00000000..1d7802ec --- /dev/null +++ b/dbcog/find_monster/idtest_mixin.py @@ -0,0 +1,557 @@ +import re +from datetime import datetime +from typing import Callable, Coroutine, List, Optional + +from redbot.core import Config, checks, commands +from redbot.core.bot import Red +from redbot.core.utils import AsyncIter +from redbot.core.utils.chat_formatting import box, pagify +from tsutils.enums import Server +from tsutils.user_interaction import get_user_confirmation, get_user_reaction + +from dbcog.models.enum_types import DEFAULT_SERVER +from dbcog.models.monster_model import MonsterModel +from dbcog.monster_index import MonsterIndex + + +class IdTest: + bot: Red + config: Config + get_index: Callable[[Server], Coroutine[None, None, MonsterIndex]] + find_monster: Callable[[int], Coroutine[None, None, MonsterModel]] + wait_until_ready: Callable[[], Coroutine[None, None, None]] + + @commands.group() + async def idtest(self, ctx): + """ID Test suite subcommands""" + + @idtest.command(name="add") + @checks.is_owner() + async def idt_add(self, ctx, mid: int, *, query): + """Add a test for the id3 test suite (Append `| ` to add a reason)""" + query, *reason = query.split("|") + query = query.strip() + if await self.config.user(ctx.author).lastaction() != 'id3' and \ + not await get_user_confirmation(ctx, "Are you sure you want to add to the id3 test suite?"): + return + await self.config.user(ctx.author).lastaction.set('id3') + + async with self.config.test_suite() as suite: + oldd = suite.get(query, {}) + if oldd.get('result') == mid: + await ctx.send(f"This test case already exists with id `{sorted(suite).index(query)}`.") + return + suite[query] = { + 'result': mid, + 'timestamp': datetime.now().timestamp(), + 'reason': reason[0].strip() if reason else '' + } + + try: + monster = await self.find_monster(query) + except Exception: + actual_mid = -2 + else: + actual_mid = -1 if monster is None else monster.monster_id + passing = actual_mid == mid + + if await get_user_reaction(ctx, f"Added {'passing' if passing else 'failing'}" + f" test case `{mid}: {query}`" + f" with ref `{sorted(suite).index(query)}`", + "\N{LEFTWARDS ARROW WITH HOOK}", timeout=5): + if oldd: + suite[query] = oldd + else: + del suite[query] + await ctx.react_quietly("\N{CROSS MARK}") + else: + await ctx.send(f"Successfully added {'passing' if passing else 'failing'}" + f" test case `{mid}: {query}`" + f" with ref `{sorted(suite).index(query)}`") + await ctx.tick() + + @idtest.group(name="name") + async def idt_name(self, ctx): + """Name subcommands""" + + @idtest.group(name="fluff") + async def idt_fluff(self, ctx): + """Fluff subcommands""" + + @idt_name.command(name="add") + @checks.is_owner() + async def idtn_add(self, ctx, mid: int, token, server: Optional[Server] = DEFAULT_SERVER, *, reason=""): + """Add a name token test to the id3 test suite""" + await self.norf_add(ctx, mid, token, server, reason, False) + + @idt_fluff.command(name="add") + @checks.is_owner() + async def idtf_add(self, ctx, mid: int, token, server: Optional[Server] = DEFAULT_SERVER, *, reason=""): + """Add a fluff token test to the id3 test suite""" + await self.norf_add(ctx, mid, token, server, reason, True) + + async def norf_add(self, ctx, mid: int, token, server, reason, fluffy): + reason = reason.lstrip("| ") + if await self.config.user(ctx.author).lastaction() != 'name' and \ + not await get_user_confirmation(ctx, "Are you sure you want to add to the" + " fluff/name test suite?"): + return + await self.config.user(ctx.author).lastaction.set('name') + + async with self.config.fluff_suite() as suite: + if any(t['id'] == mid and t['token'] == token and t['fluff'] == fluffy for t in suite): + await ctx.send("This test already exists.") + return + + old = None + if any(t['id'] == mid and t['token'] == token for t in suite): + old = [t for t in suite if t['id'] == mid and t['token'] == token][0] + if not await get_user_confirmation(ctx, f"Are you sure you want to change" + f" the type of test case #{suite.index(old)}" + f" `{mid}: {token}` from " + f" **{'fluff' if fluffy else 'name'}** to" + f" **{'name' if fluffy else 'fluff'}**?"): + await ctx.react_quietly("\N{CROSS MARK}") + return + suite.remove(old) + + case = { + 'id': mid, + 'token': token, + 'fluff': fluffy, + 'reason': reason, + 'server': server.name, + 'timestamp': datetime.now().timestamp() + } + + suite.append(case) + suite.sort(key=lambda v: (v['id'], v['token'], v['fluff'])) + + await self.wait_until_ready() + fluff = mid in [m.monster_id for m in (await self.get_index(server)).fluff_tokens[token]] + name = mid in [m.monster_id for m in (await self.get_index(server)).name_tokens[token]] + passing = (fluffy and fluff) or (not fluffy and name) + + if await get_user_reaction(ctx, f"Added {'passing' if passing else 'failing'}" + f" {'fluff' if fluffy else 'name'} " + f"case `{mid}: {token}` with ref `{suite.index(case)}`", + "\N{LEFTWARDS ARROW WITH HOOK}", timeout=5): + suite.remove(case) + if old: + suite.append(old) + suite.sort(key=lambda v: (v['id'], v['token'], v['fluff'])) + await ctx.react_quietly("\N{CROSS MARK}") + else: + m = await ctx.send(f"Successfully added {'passing' if passing else 'failing'}" + f" {'fluff' if fluffy else 'name'} " + f"case `{mid}: {token}` with ref `{suite.index(case)}`") + await m.add_reaction("\N{WHITE HEAVY CHECK MARK}") + + @idtest.command(name="import") + @checks.is_owner() + async def idt_import(self, ctx, *, queries): + """Import id3 tests""" + if await self.config.user(ctx.author).lastaction() != 'id3' and \ + not await get_user_confirmation(ctx, "Are you sure you want to edit **query**?"): + return + await self.config.user(ctx.author).lastaction.set('id3') + + cases = re.findall(r'\s*(?:\d+. )?(.+?) +- (-?\d+) *(.*)', queries) + async with self.config.test_suite() as suite: + for query, result, reason in cases: + suite[query] = {'result': int(result), 'reason': reason, 'timestamp': datetime.now().timestamp()} + await ctx.tick() + + @idt_name.command(name="import") + @checks.is_owner() + async def idtn_import(self, ctx, *, queries): + """Import name/fluff tests""" + await self.norf_import(ctx, queries) + + @idt_fluff.command(name="import") + @checks.is_owner() + async def idtf_import(self, ctx, *, queries): + """Import name/fluff tests""" + await self.norf_import(ctx, queries) + + async def norf_import(self, ctx, queries): + if await self.config.user(ctx.author).lastaction() != 'name' and \ + not await get_user_confirmation(ctx, "Are you sure you want to edit **name/fluff**?"): + return + await self.config.user(ctx.author).lastaction.set('name') + + cases = re.findall(r'\s*(?:\d+. )?(.+?) +- (\d+)\s+(\w*) *(.*)', queries) + async with self.config.fluff_suite() as suite: + for query, result, fluff, reason in cases: + if not any(c['id'] == int(result) and c['token'] == query for c in suite): + suite.append({ + 'id': int(result), + 'token': query, + 'fluff': fluff == 'fluff', + 'reason': reason, + 'server': "COMBINED", + 'timestamp': datetime.now().timestamp()}) + await ctx.tick() + + @idtest.command(name="remove", aliases=["rm", "delete", "del"]) + @checks.is_owner() + async def idt_remove(self, ctx, *, item): + """Remove an id3 test""" + if await self.config.user(ctx.author).lastaction() != 'id3' and \ + not await get_user_confirmation(ctx, "Are you sure you want to edit **query**?"): + return + await self.config.user(ctx.author).lastaction.set('id3') + + async with self.config.test_suite() as suite: + if item in suite: + case = item + elif item.isdigit() and int(item) < len(suite): + case = sorted(suite)[int(item)] + else: + await ctx.react_quietly("\N{CROSS MARK}") + return + res = suite[case]['result'] + ref = sorted(suite).index(case) + del suite[case] + await ctx.send(f"Removed test case `{case}: {res}` with ref {ref}") + + @idt_name.command(name="remove", aliases=["rm", "delete", "del"]) + @checks.is_owner() + async def idtn_remove(self, ctx, *, item: int): + """Remove a name/fluff test""" + await self.norf_remove(ctx, item) + + @idt_fluff.command(name="remove", aliases=["rm", "delete", "del"]) + @checks.is_owner() + async def idtf_remove(self, ctx, *, item: int): + """Remove a name/fluff test""" + await self.norf_remove(ctx, item) + + async def norf_remove(self, ctx, item): + if await self.config.user(ctx.author).lastaction() != 'name' and \ + not await get_user_confirmation(ctx, "Are you sure you want to edit **name/fluff**?"): + return + await self.config.user(ctx.author).lastaction.set('name') + + async with self.config.fluff_suite() as suite: + if item >= len(suite): + await ctx.send("There are not that many items.") + return + case = sorted(suite, key=lambda v: (v['id'], v['token'], v['fluff']))[item] + suite.remove(case) + # noinspection PyTypeChecker + await ctx.send(f"Successfully removed {'fluff' if case['fluff'] else 'name'} case" + f" `{case['id']}: {case['token']}`") + + @idtest.command(name="setreason", aliases=["addreason"]) + @checks.is_owner() + async def idt_setreason(self, ctx, number: int, *, reason): + """Set a reason for an id3 test case""" + if reason == '""': + reason = "" + if await self.config.user(ctx.author).lastaction() != 'id3' and \ + not await get_user_confirmation(ctx, "Are you sure you want to edit **query**?"): + return + await self.config.user(ctx.author).lastaction.set('id3') + + async with self.config.test_suite() as suite: + if number >= len(suite): + await ctx.react_quietly("\N{CROSS MARK}") + return + suite[sorted(suite)[number]]['reason'] = reason + await ctx.tick() + + @idt_name.command(name="setreason") + @checks.is_owner() + async def idtn_setreason(self, ctx, number: int, *, reason): + """Set a reason for an name/fluff test case""" + await self.norf_setreason(ctx, number, reason) + + @idt_fluff.command(name="setreason") + @checks.is_owner() + async def idtf_setreason(self, ctx, number: int, *, reason): + """Set a reason for an name/fluff test case""" + await self.norf_setreason(ctx, number, reason) + + async def norf_setreason(self, ctx, number, reason): + if reason == '""' or reason == "''": + reason = "" + if await self.config.user(ctx.author).lastaction() != 'name' and \ + not await get_user_confirmation(ctx, "Are you sure you want to edit **name/fluff**?"): + return + await self.config.user(ctx.author).lastaction.set('name') + + async with self.config.fluff_suite() as suite: + if number >= len(suite): + await ctx.react_quietly("\N{CROSS MARK}") + return + # noinspection PyTypeChecker + sorted(suite, key=lambda v: (v['id'], v['token'], v['fluff']))[number]['reason'] = reason + await ctx.tick() + + @idtest.command(name="list") + async def idt_list(self, ctx): + """List id3 tests""" + await self.config.user(ctx.author).lastaction.set('id3') + + suite = await self.config.test_suite() + o = "" + ml = len(max(suite, key=len)) + for c, kv in enumerate(sorted(suite.items())): + o += f"{str(c).rjust(3)}. {kv[0].ljust(ml)}: {str(kv[1]['result']).ljust(4)}\t{kv[1].get('reason') or ''}\n" + if not o: + await ctx.send("There are no test cases.") + for page in pagify(o): + await ctx.send(box(page)) + + @idt_name.command(name="list") + async def idtn_list(self, ctx, inclusive: bool = False): + """List name tests""" + await self.norf_list(ctx, False, inclusive) + + @idt_fluff.command(name="list") + async def idtf_list(self, ctx, inclusive: bool = False): + """List fluff tests""" + await self.norf_list(ctx, True, inclusive) + + async def norf_list(self, ctx, fluff, inclusive): + await self.config.user(ctx.author).lastaction.set('name') + + suite = await self.config.fluff_suite() + o = "" + for c, case in enumerate(sorted(suite, key=lambda v: (v['id'], v['token'], v['fluff']))): + if inclusive or case['fluff'] == fluff: + o += f"{str(c).rjust(3)}. {case['token'].ljust(10)}: {str(case['id']).ljust(4)}" \ + f"\t{'fluff' if case['fluff'] else 'name '}\t{case.get('reason', '')}\n" + if not o: + await ctx.send("There are no test cases.") + for page in pagify(o): + await ctx.send(box(page)) + + @idtest.command(name="listnoreason") + async def idt_listnoreason(self, ctx): + """List id3 tests with no reasons""" + await self.config.user(ctx.author).lastaction.set('id3') + + suite = await self.config.test_suite() + o = "" + ml = len(max(suite, key=len)) + for c, kv in enumerate(sorted(suite.items())): + if not kv[1].get('reason'): + o += f"{str(c).rjust(3)}. {kv[0].ljust(ml)}: {str(kv[1]['result']).ljust(4)}\t{kv[1].get('reason') or ''}\n" + if not o: + await ctx.send("There are no test cases.") + for page in pagify(o): + await ctx.send(box(page)) + + @idt_name.command(name="listnoreason") + async def idtn_listnoreason(self, ctx, inclusive: bool = False): + """List name tests with no reasons""" + await self.norf_listnoreason(ctx, False, inclusive) + + @idt_fluff.command(name="listnoreason") + async def idtf_lisnoreasont(self, ctx, inclusive: bool = False): + """List fluff tests with no reasons""" + await self.norf_listnoreason(ctx, True, inclusive) + + async def norf_listnoreason(self, ctx, fluff, inclusive): + await self.config.user(ctx.author).lastaction.set('name') + + suite = await self.config.fluff_suite() + o = "" + for c, case in enumerate(sorted(suite, key=lambda v: (v['id'], v['token'], v['fluff']))): + if inclusive or case['fluff'] == fluff and not case['reason']: + o += f"{str(c).rjust(3)}. {case['token'].ljust(10)}: {str(case['id']).ljust(4)}" \ + f"\t{'fluff' if case['fluff'] else 'name '}\t{case.get('reason', '')}\n" + if not o: + await ctx.send("There are no test cases.") + for page in pagify(o): + await ctx.send(box(page)) + + @idtest.command(name="listrecent") + async def idt_listrecent(self, ctx, count: int = 0): + """List recent id3 tests""" + suite = await self.config.test_suite() + if count == 0: + count = len(suite) + o = "" + ml = len(max(suite, key=len)) + for c, kv_tuple in enumerate(sorted(suite.items(), + key=lambda kv: kv[1].get('timestamp', 0), + reverse=True)[:count]): + key, val = kv_tuple + o += f"{key.ljust(ml)}: {str(val['result']).ljust(4)}\t{val.get('reason') or ''}\n" + if not o: + await ctx.send("There are no test cases.") + for page in pagify(o): + await ctx.send(box(page)) + + @idtest.command(name="run", aliases=["test"]) + async def idt_run(self, ctx): + """Run all id3 tests""" + suite = await self.config.test_suite() + if not suite: + await ctx.send("No tests found.") + return + await self.config.user(ctx.author).lastaction.set('id3') + c = 0 + o = "" + ml = len(max(suite, key=len)) + 2 + rcircle = '\N{LARGE RED CIRCLE}' + async with ctx.typing(): + async for i, qr in AsyncIter(enumerate(sorted(suite.items()))): + q, r = qr + try: + monster = await self.find_monster(q) + except Exception: + mid = -2 + else: + mid = -1 if monster is None else monster.monster_id + + if mid != r['result']: + reason = ' Reason: ' + r.get('reason') if 'reason' in r else '' + q = '"' + q + '"' + o += f"{i}. {q.ljust(ml)}: {rcircle} Ex: {r['result']}, Ac: {mid}{reason}\n" + else: + c += 1 + if c != len(suite): + o += f"\n\nTests complete. {c}/{len(suite)} succeeded." + else: + o += "\n\n\N{LARGE GREEN CIRCLE} All tests succeeded!" + for page in pagify(o): + await ctx.send(box(page)) + + @idt_name.command(name="run") + async def idtn_run(self, ctx): + """Run all name/fluff tests""" + await self.norf_run(ctx) + + @idt_fluff.command(name="run") + async def idtf_run(self, ctx): + """Run all name/fluff tests""" + await self.norf_run(ctx) + + async def norf_run(self, ctx): + """Run all name/fluff tests""" + suite = await self.config.fluff_suite() + if not suite: + await ctx.send("No tests found.") + return + await self.config.user(ctx.author).lastaction.set('name') + c = 0 + o = "" + rcircle, ycircle = '\N{LARGE RED CIRCLE}', '\N{LARGE YELLOW CIRCLE}' + async with ctx.typing(): + async for i, case in AsyncIter(enumerate(sorted(suite, key=lambda v: (v['id'], + v['token'], + v['fluff'], + v['server'])))): + fluff = case['id'] in [m.monster_id for m in + (await self.get_index(Server(case['server']))).fluff_tokens[case['token']]] + name = case['id'] in [m.monster_id for m in + (await self.get_index(Server(case['server']))).name_tokens[case['token']]] + + if (case['fluff'] and not fluff) or (not case['fluff'] and not name): + q = '"{}"'.format(case['token']) + o += f"{i}. {str(case['id']).ljust(4)} {q.ljust(10)}: " \ + f"{ycircle if name or fluff else rcircle} " \ + f"Not {'Fluff' if name else 'Name' if fluff else 'A'} Token\n" + else: + c += 1 + if c != len(suite): + o += f"\n\nTests complete. {c}/{len(suite)} succeeded." + else: + o += "\n\n\N{LARGE GREEN CIRCLE} All tests succeeded." + for page in pagify(o): + await ctx.send(box(page)) + + @idtest.command(name="runall") + async def idt_runall(self, ctx): + """Run all tests""" + rcircle, ycircle = '\N{LARGE RED CIRCLE}', '\N{LARGE YELLOW CIRCLE}' + qsuite = await self.config.test_suite() + qo = "" + qc = 0 + ml = len(max(qsuite or [''], key=len)) + 2 + + fsuite = await self.config.fluff_suite() + fo = "" + fc = 0 + async with ctx.typing(): + async for c, q in AsyncIter(enumerate(sorted(qsuite))): + try: + monster = await self.find_monster(q) + except Exception: + mid = -2 + else: + mid = -1 if monster is None else monster.monster_id + + if mid != qsuite[q]['result']: + reason = ' Reason: ' + qsuite[q].get('reason') if qsuite[q].get('reason') else '' + qq = '"' + q + '"' + qo += (f"{str(c).rjust(4)}. {qq.ljust(ml)}: {rcircle} " + f"Ex: {qsuite[q]['result']}, Ac: {mid}{reason}\n") + else: + qc += 1 + + async for c, v in AsyncIter(enumerate(fsuite)): + fluff = v['id'] in [m.monster_id for m + in (await self.get_index(Server(v['server']))).fluff_tokens[v['token']]] + name = v['id'] in [m.monster_id for m + in (await self.get_index(Server(v['server']))).name_tokens[v['token']]] + + if (v['fluff'] and not fluff) or (not v['fluff'] and not name): + q = '"{}"'.format(v['token']) + fo += f"{str(c).rjust(4)}. {str(v['id']).ljust(4)} {q.ljust(ml - 5)}: " \ + f"{ycircle if name or fluff else rcircle} " \ + f"Not {'Fluff' if name else 'Name' if fluff else 'A'} Token\n" + else: + fc += 1 + + o = "" + if fo: + o += "[Failed Token Tests]\n" + fo + if qo: + o += "\n[Failed Query Tests]\n" + qo + + if qc + fc != len(fsuite) + len(qsuite): + o += f"\n\nTests complete. {qc + fc}/{len(fsuite) + len(qsuite)} succeeded." + else: + o += "\n\n\N{LARGE GREEN CIRCLE} \N{LARGE GREEN CIRCLE} All tests succeeded!!" + for page in pagify(o): + await ctx.send(box(page)) + + async def run_tests(self) -> List[str]: + """Run all tests""" + rcircle, ycircle = '\N{LARGE RED CIRCLE}', '\N{LARGE YELLOW CIRCLE}' + failures = [] + + qsuite = await self.config.test_suite() + ml = len(max(qsuite or [''], key=len)) + 2 + async for c, q in AsyncIter(enumerate(sorted(qsuite))): + try: + monster = await self.find_monster(q) + except Exception: + mid = -2 + else: + mid = -1 if monster is None else monster.monster_id + + if mid != qsuite[q]['result']: + reason = ' Reason: ' + qsuite[q].get('reason') if qsuite[q].get('reason') else '' + qq = '"' + q + '"' + failures.append(f"{str(c).rjust(4)}. {qq.ljust(ml)}: {rcircle} " + f"Ex: {qsuite[q]['result']}, Ac: {mid}{reason}") + + fsuite = await self.config.fluff_suite() + async for c, v in AsyncIter(enumerate(fsuite)): + fluff = v['id'] in [m.monster_id for m + in (await self.get_index(Server(v['server']))).fluff_tokens[v['token']]] + name = v['id'] in [m.monster_id for m + in (await self.get_index(Server(v['server']))).name_tokens[v['token']]] + + if (v['fluff'] and not fluff) or (not v['fluff'] and not name): + q = '"{}"'.format(v['token']) + failures.append(f"{str(c).rjust(4)}. {str(v['id']).ljust(4)} {q.ljust(ml - 5)}: " + f"{ycircle if name or fluff else rcircle} " + f"Not {'Fluff' if name else 'Name' if fluff else 'A'} Token") + return failures diff --git a/dbcog/find_monster/token_mappings.py b/dbcog/find_monster/token_mappings.py new file mode 100644 index 00000000..d2e0fa7d --- /dev/null +++ b/dbcog/find_monster/token_mappings.py @@ -0,0 +1,405 @@ +from collections import defaultdict, namedtuple +from enum import Enum +from typing import Mapping, Tuple, TypeVar + +from dbcog.models.enum_types import Attribute, AwokenSkills, MonsterType + +K = TypeVar('K') +V = TypeVar('V') + + +def inverse_map(token_map: Mapping[K, Tuple[V]]) -> Mapping[V, Tuple[K]]: + ret = defaultdict(tuple) + for k, vs in token_map.items(): + for v in vs: + ret[v] += (k,) + return ret + + +COLOR_MAP = { + Attribute.Fire: ('r', 'red', 'fire'), + Attribute.Water: ('b', 'blue', 'water'), + Attribute.Wood: ('g', 'green', 'wood'), + Attribute.Light: ('l', 'light', 'yellow'), + Attribute.Dark: ('d', 'dark', 'purple'), + Attribute.Nil: ('nil', 'x', 'none', 'null', 'white') +} + +SUB_COLOR_MAP = {k: tuple('?' + t for t in v if t != "white") for k, v in COLOR_MAP.items()} + +DUAL_COLOR_MAP = {} +for cid1, cns1 in COLOR_MAP.items(): + for cid2, cns2 in COLOR_MAP.items(): + _ts = () + for t1 in cns1: + for t2 in cns2: + if t2 in ("white",): + continue + if len(t1) + len(t2) == 2: + _ts += (t1 + t2,) + if (len(t1) == 1) == (len(t2) == 1): + _ts += (t1 + "/" + t2,) + DUAL_COLOR_MAP[(cid1, cid2)] = _ts + +TYPE_MAP = { + MonsterType.Evolve: ('evolve',), + MonsterType.Balanced: ('balanced', 'bal', 'balance'), + MonsterType.Physical: ('physical', 'phys'), + MonsterType.Healer: ('healer',), + MonsterType.Dragon: ('dragon', 'dra'), + MonsterType.God: ('god',), + MonsterType.Attacker: ('attacker', 'atk'), + MonsterType.Devil: ('devil', 'dv'), + MonsterType.Machine: ('machine', 'mech'), + MonsterType.Awoken: ('awokentype', 'awotype'), + MonsterType.Enhance: ('enhance', 'fodder', 'enh'), + MonsterType.Vendor: ('vendor', 'redeemable'), +} + + +class EvoTypes(Enum): + BASE = 'Base' + EVO = 'Evolved' + UVO = 'Ulimate' + UUVO = 'Super Ultimate' + EQUIP = 'Equip' + BASETRANS = 'Base Transform' + TRANS = 'Transform' + AWOKEN = 'Awoken' + MEGA = 'Mega Awoken' + REVO = 'Reincarnated' + SREVO = 'Super Reincarnated' + PIXEL = 'Pixel' + NONPIXEL = 'Nonpixel' + CHIBI = 'Chibi' + + +EVO_MAP = { + EvoTypes.BASE: ('base',), + EvoTypes.EVO: ('evo', 'evolved'), + EvoTypes.UVO: ('uvo', 'ult', 'ultimate', 'uevo'), + EvoTypes.UUVO: ('uuvo', 'uult', 'uultimate', 'uuevo', 'suvo'), + EvoTypes.EQUIP: ('equip', 'assist', 'eq'), + EvoTypes.BASETRANS: ('transformbase', 'transbase'), + EvoTypes.TRANS: ('transform', 'trans', 'transformed', 'xf', 'xform', 'tf'), + EvoTypes.AWOKEN: ('awoken', 'awo', 'a'), + EvoTypes.MEGA: ('mega', 'mawoken', 'mawo', 'ma', 'megaawoken'), + EvoTypes.REVO: ('revo', 'reincarnated', 'rv'), + EvoTypes.SREVO: ('srevo', 'super', 'sr', 'superreincarnated'), + EvoTypes.PIXEL: ('pixel', 'p', 'dot', 'px'), + EvoTypes.NONPIXEL: ('nonpixel', 'np'), + EvoTypes.CHIBI: ('chibi', 'mini'), +} + +# awakening view state & awakening help props in padinfo are transforming this dict to `int: tuple` +# so rewrite those two if this gets refactored at some point in the future! +AWOKEN_SKILL_MAP = { + AwokenSkills.ENHANCEDHP: ('hp+', 'hp'), + AwokenSkills.ENHANCEDATK: ('atk+', 'atk'), + AwokenSkills.ENHANCEDRCV: ('rcv+', 'rcv'), + AwokenSkills.REDUCERED: ('elresr', 'elres'), # element resist + AwokenSkills.REDUCEBLUE: ('elresb', 'elres'), + AwokenSkills.REDUCEGREEN: ('elresg', 'elres'), + AwokenSkills.REDUCELIGHT: ('elresl', 'elres'), + AwokenSkills.REDUCEDARK: ('elresd', 'elres'), + AwokenSkills.AUTOHEAL: ('autoheal',), + AwokenSkills.BINDRES: ('unbindable', 'bindres'), + AwokenSkills.BLINDRES: ('resb', 'bres'), + AwokenSkills.JAMMERRES: ('resj', 'jres'), + AwokenSkills.POISONRES: ('resp', 'pres'), + AwokenSkills.ENHANCEDRED: ('oer', 'oe'), + AwokenSkills.ENHANCEDBLUE: ('oeb', 'oe'), + AwokenSkills.ENHANCEDGREEN: ('oeg', 'oe'), + AwokenSkills.ENHANCEDLIGHT: ('oel', 'oe'), + AwokenSkills.ENHANCEDDARK: ('oed', 'oe'), + AwokenSkills.EXTMOVE: ('te', 'finger'), + AwokenSkills.BINDRECOVERY: ('bindrcv', 'bindclear', 'rowclear'), + AwokenSkills.SKILLBOOST: ('sb',), + AwokenSkills.REDROW: ('rowr', 'row'), + AwokenSkills.BLUEROW: ('rowb', 'row'), + AwokenSkills.GREENROW: ('rowg', 'row'), + AwokenSkills.LIGHTROW: ('rowl', 'row'), + AwokenSkills.DARKROW: ('rowd', 'row'), + AwokenSkills.TPA: ('tpa', 'pronged'), + AwokenSkills.SKILLBINDRES: ('sbr',), + AwokenSkills.ENHANCEDHEAL: ('htpa', 'oeh'), + AwokenSkills.MULTIBOOST: ('multi', 'mb'), + AwokenSkills.DRAGONKILLER: ('dragonkiller', 'dk', 'drk', 'killer'), + AwokenSkills.GODKILLER: ('godkiller', 'gk', 'gok', 'killer'), + AwokenSkills.DEVILKILLER: ('devilkiller', 'vk', 'dek', 'killer'), + AwokenSkills.MACHINEKILLER: ('machinekiller', 'mk', 'mak', 'killer'), + AwokenSkills.BALANCEDKILLER: ('balancedkiller', 'bk', 'bak', 'killer'), + AwokenSkills.ATTACKERKILLER: ('attackerkiller', 'ak', 'aak', 'killer'), + AwokenSkills.PHYSICALKILLER: ('physicalkiller', 'pk', 'phk', 'killer'), + AwokenSkills.HEALERKILLER: ('healerkiller', 'hk', 'hek', 'killer'), + AwokenSkills.EVOMATKILLER: ('evokiller', 'evok', 'a2killer'), + AwokenSkills.AWOKENKILLER: ('awokenkiller', 'awok', 'a2killer'), + AwokenSkills.FODDERKILLER: ('enhancekiller', 'enhk', 'a2killer'), + AwokenSkills.REDEEMKILLER: ('vendorkiller', 'vendork', 'a2killer'), + AwokenSkills.ENHCOMBO7C: ('7c',), + AwokenSkills.GUARDBREAK: ('guardbreak', 'gbr'), + AwokenSkills.FUA: ('fua',), + AwokenSkills.ENHTEAMHP: ('teamhp', 'thp'), + AwokenSkills.ENHTEAMRCV: ('teamrcv', 'trcv'), + AwokenSkills.VDP: ('vdp', 'box'), + AwokenSkills.EQUIP: (), # Equip is an evo type + AwokenSkills.SUPERFUA: ('sfua',), + AwokenSkills.SKILLCHARGE: ('rainbowhaste', 'skillcharge', 'hasteawo'), + AwokenSkills.UNBINDABLE: ('unbindable', 'bindres'), + AwokenSkills.EXTMOVEPLUS: ('te+', 'te', 'finger+', 'finger'), + AwokenSkills.CLOUDRESIST: ('cloudres', 'cloud'), + AwokenSkills.TAPERESIST: ('taperes', 'tape'), + AwokenSkills.SKILLBOOSTPLUS: ('sb+', 'sb'), + AwokenSkills.HP80ORMORE: ('>80', 'highhp'), + AwokenSkills.HP50ORLESS: ('<50', 'lowhp'), + AwokenSkills.ELSHIELD: ('elshield', 'elh', 'hel'), + AwokenSkills.ELATTACK: ('el',), + AwokenSkills.ENHCOMBO10C: ('10c',), + AwokenSkills.COMBOORB: ('co', 'corb'), + AwokenSkills.VOICE: ('voice',), + AwokenSkills.DUNGEONBONUS: ('dgbonus', 'dgboost', 'dgb', 'dungeonboost', 'dungeonbonus'), + AwokenSkills.REDUCEDHP: ('hp-',), + AwokenSkills.REDUCEDATK: ('atk-',), + AwokenSkills.REDUCEDRCV: ('rcv-',), + AwokenSkills.UNBLINDABLE: ('resb+', 'b+',), + AwokenSkills.UNJAMMABLE: ('resj+', 'j+',), + AwokenSkills.UNPOISONABLE: ('resp+', 'p+',), + AwokenSkills.JAMMERBLESSING: ('jblessing', 'sfj', 'jsurge'), + AwokenSkills.POISONBLESSING: ('pblessing', 'sfp', 'psurge'), + AwokenSkills.REDCOMBOCOUNT: ('ccr', 'cc'), + AwokenSkills.BLUECOMBOCOUNT: ('ccb', 'cc'), + AwokenSkills.GREENCOMBOCOUNT: ('ccg', 'cc'), + AwokenSkills.LIGHTCOMBOCOUNT: ('ccl', 'cc'), + AwokenSkills.DARKCOMBOCOUNT: ('ccd', 'cc'), + AwokenSkills.CROSSATTACK: ('crossattack', 'crossblind'), + AwokenSkills.ATTR3BOOST: ('attr3', '3attr'), + AwokenSkills.ATTR4BOOST: ('attr4', '4attr'), + AwokenSkills.ATTR5BOOST: ('attr5', '5attr'), + AwokenSkills.BLOBBOOST: ('blobboost', 'blob', 'brickboost', 'brick'), + AwokenSkills.ADDTYPEDRAGON: ('typed', 'typedragon'), + AwokenSkills.ADDTYPEGOD: ('typeg', 'typegod'), + AwokenSkills.ADDTYPEDEVIL: ('typev', 'typedevil'), + AwokenSkills.ADDTYPEMACHINE: ('typem', 'typemachine'), + AwokenSkills.ADDTYPEBALANCED: ('typeb', 'typebalance', 'typebal', 'typebalanced'), + AwokenSkills.ADDTYPEATTACKER: ('typea', 'typeattacker'), + AwokenSkills.ADDTYPEPHYSICAL: ('typep', 'typephys', 'typephysical'), + AwokenSkills.ADDTYPEHEALER: ('typeh', 'typehealer'), + AwokenSkills.SUBATTRRED: ('subattrr', '?r+', 'subattr'), + AwokenSkills.SUBATTRBLUE: ('subattrb', '?b+', 'subattb'), + AwokenSkills.SUBATTRGREEN: ('subattrg', '?g+', 'subattg'), + AwokenSkills.SUBATTRLIGHT: ('subattrl', '?l+', 'subattl'), + AwokenSkills.SUBATTRDARK: ('subattrd', '?d+', 'subattd'), + AwokenSkills.TPAPLUS: ('tpa+', 'pronged+', 'tpa', 'pronged'), + AwokenSkills.SKILLCHARGEPLUS: ('rainbowhaste+', 'skillcharge+', 'hasteawo+', 'rainbowhaste', 'skillcharge', 'hasteawo'), + AwokenSkills.AUTOHEALPLUS: ('autoheal+', 'autoheal'), + AwokenSkills.ENHANCEDREDPLUS: ('oer+', 'oe+', 'oer', 'oe'), + AwokenSkills.ENHANCEDBLUEPLUS: ('oeb+', 'oe+', 'oeb', 'oe'), + AwokenSkills.ENHANCEDGREENPLUS: ('oeg+', 'oe+', 'oeg', 'oe'), + AwokenSkills.ENHANCEDLIGHTPLUS: ('oel+', 'oe+', 'oel', 'oe'), + AwokenSkills.ENHANCEDDARKPLUS: ('oed+', 'oe+','oed', 'oe'), + AwokenSkills.ENHANCEDHEALPLUS: ('htpa+', 'oeh+', 'oeh', 'oe'), +} + + +class MiscModifiers(Enum): + STORY = 'Story' + FARMABLE = 'Farmable' + TRADEABLE = 'Tradeable' + REM = 'In REM' + PEM = 'In PEM' + ADPEM = 'In AdPEM' + INADPEM = 'This evo is in AdPEM' + MP = 'MP' + INJP = 'In JP Server' + ONLYJP = 'Only in JP Server' + INNA = 'In NA Server' + ONLYNA = 'Only in NA Server' + REGULAR = 'Metaseries: REGULAR' + EVENT = 'Metaseries: Event' + SEASONAL = 'Metaseries: Seasonal' + COLLAB = 'Metaseries: Collab' + NEW = 'Newest monster in series' + ORBSKIN = 'Grants an orb skin' + BGM = 'Grants a BGM' + ANIMATED = 'Animated monster' + MEDAL_EXC = 'Exchangable for vendor mats' + BLACK_MEDAL = 'Exchangable for black medals' + CURRENT_EXCHANGE_JP = 'Currently exchangable in JP' + CURRENT_EXCHANGE_NA = 'Currently exchangable in NA' + CURRENT_EXCHANGE_KR = 'Currently exchangable in KR' + PERMANENT_EXCHANGE = 'Permanently exchangable' + TEMP_EXCHANGE = 'Temporarily exchangable at some point in time' + HAS_GEM = 'Has an evo gem' + GFESHOP = 'GFE exchangable' + GFESHOP6S = '6* GFE exchangable' + GFESHOP7S = '7* GFE exchangable' + + +MISC_MAP = { + MiscModifiers.STORY: ('story',), + MiscModifiers.FARMABLE: ('farmable',), + MiscModifiers.TRADEABLE: ('tradeable', 'tradable'), + MiscModifiers.REM: ('rem',), + MiscModifiers.PEM: ('pem',), + MiscModifiers.ADPEM: ('adpem', 'adpem'), + MiscModifiers.INADPEM: ('invem', 'inadpem'), + MiscModifiers.MP: ('mp',), + MiscModifiers.INJP: ('injp',), + MiscModifiers.INNA: ('inna',), + MiscModifiers.ONLYJP: ('jp',), + MiscModifiers.ONLYNA: ('na',), + MiscModifiers.REGULAR: ('regular',), + MiscModifiers.EVENT: ('event',), + MiscModifiers.SEASONAL: ('seasonal',), + MiscModifiers.COLLAB: ('collab',), + MiscModifiers.NEW: ('new',), + MiscModifiers.ORBSKIN: ('orbskin',), + MiscModifiers.BGM: ('bgm',), + MiscModifiers.ANIMATED: ('animated',), + MiscModifiers.MEDAL_EXC: ('medal', 'shop'), + MiscModifiers.BLACK_MEDAL: ('blackmetal',), + MiscModifiers.CURRENT_EXCHANGE_JP: ('nowshopjp', 'shopnowjp'), + MiscModifiers.CURRENT_EXCHANGE_NA: ('nowshopna', 'shopnowna'), + MiscModifiers.CURRENT_EXCHANGE_KR: ('nowshopkr', 'shopnowkr'), + MiscModifiers.PERMANENT_EXCHANGE: ('permshop', 'shopperm'), + MiscModifiers.TEMP_EXCHANGE: ('tempshop', 'shoptemp'), + MiscModifiers.HAS_GEM: ('hasgem',), + MiscModifiers.GFESHOP: ('gfeshop',), + MiscModifiers.GFESHOP6S: ('6*gfeshop', 'gfeshop6*'), + MiscModifiers.GFESHOP7S: ('7*gfeshop', 'gfeshop7*'), +} + +MULTI_WORD_TOKENS = {tuple(ts.split()) for ts in { + 'super reincarnated', + 'mega awoken', + 'orb skin', + 'black metal', +}} + +ALL_TOKEN_DICTS = { + *COLOR_MAP.values(), + *SUB_COLOR_MAP.values(), + *DUAL_COLOR_MAP.values(), + *TYPE_MAP.values(), + *AWOKEN_SKILL_MAP.values(), + *EVO_MAP.values(), + *MISC_MAP.values(), +} + +KNOWN_MODIFIERS = {v for vs in ALL_TOKEN_DICTS for v in vs} + +COLOR_TOKENS = { + *sum(COLOR_MAP.values(), ()), + *sum(SUB_COLOR_MAP.values(), ()), + *sum(DUAL_COLOR_MAP.values(), ()), +} + +AWAKENING_TOKENS = {*sum(AWOKEN_SKILL_MAP.values(), ())} +EVO_TOKENS = {*sum(EVO_MAP.values(), ())} +TYPE_TOKENS = {*sum(TYPE_MAP.values(), ())} + +OTHER_HIDDEN_TOKENS = set() \ + .union(COLOR_TOKENS) \ + .union(AWAKENING_TOKENS) \ + .union(EVO_TOKENS) \ + .union(TYPE_TOKENS) + +LEGAL_END_TOKENS = { + "equip", + "assist", + "eq", +} + +# These tokens have been found to be harmful and will only be added to monsters explicitly. +HAZARDOUS_IN_NAME_MODS = { + "reincarnated", + "awoken", + "equip", +} + +PROBLEMATIC_SERIES_TOKENS = { + "sonia", + "odin", + "metatron", + "kali", + "fenrir", + "sherias", +} + +# This probably doesn't belong in here +PlusAwakeningData = namedtuple("PlusAwakeningData", "awoken_skill value") +PLUS_AWOKENSKILL_MAP = { + AwokenSkills.UNBINDABLE: PlusAwakeningData(AwokenSkills.BINDRES, 2), + AwokenSkills.EXTMOVEPLUS: PlusAwakeningData(AwokenSkills.EXTMOVE, 2), + AwokenSkills.SKILLBOOSTPLUS: PlusAwakeningData(AwokenSkills.SKILLBOOST, 2), +} + +NUMERIC_MONSTER_ATTRIBUTE_ALIASES = { + (('monster_no_na',),): ('monsterid', 'monsterno', 'monster#'), + (('base_evo_id',),): ('baseid',), + (('superawakening_count',),): ('sacount',), + (('leader_skill', 'leader_skill_id'),): ('lsid',), + (('active_skill', 'active_skill_id'),): ('asid',), + (('series', 'series_id'),): ('sid', 'seriesid'), + (('rarity',),): ('rarity', 'rare'), + (('series_id',),): ('seriesid', 'series#'), + (('rcv_min',),): ('minrcv',), + (('buy_mp',),): ('buymp',), + (('sell_mp',),): ('sellmp',), + (('sell_gold',),): ('gold', 'coins'), + (('cost',),): ('cost', 'teamcost'), + (('exp',),): ('exp', 'exptomax', 'xptomax'), + (('fodder_exp',),): ('fodderexp',), + (('level',),): ('maxlvl', 'maxlevel'), + (('latent_slots',),): ('latentslots',), + (('hp_max',),): ('hp', 'maxhp'), + (('atk_max',),): ('atk', 'maxatk'), + (('rcv_max',),): ('rcv', 'maxrcv'), + (('hp_min',),): ('minhp',), + (('atk_min',),): ('minatk',), + (('rcv_min',),): ('minrcv',), +} +NUMERIC_MONSTER_ATTRIBUTE_NAMES = {*sum(NUMERIC_MONSTER_ATTRIBUTE_ALIASES.values(), ())} + +STRING_MONSTER_ATTRIBUTE_ALIASES = { + (('name_en',), + ('name_ja',)): ('monstername', 'cardname'), + (('leader_skill', 'name_en'), + ('leader_skill', 'name_ja'), + ('active_skill', 'name_en'), + ('active_skill', 'name_ja')): ('skillname',), + (('leader_skill', 'desc_en'), + ('leader_skill', 'desc_ja'), + ('active_skill', 'desc_en'), + ('active_skill', 'desc_ja')): ('skilltext',), + (('leader_skill', 'name_en'), + ('leader_skill', 'name_ja')): ('lsname',), + (('leader_skill', 'desc_en'), + ('leader_skill', 'desc_ja')): ('lsdesc',), + (('active_skill', 'name_en'), + ('active_skill', 'name_ja')): ('asname',), + (('active_skill', 'desc_en'), + ('active_skill', 'desc_ja')): ('asdesc',), + (('series', 'name_en'),): ('seriesname',), + (('history_us',),): ('regdate', 'dateadded'), +} +STRING_MONSTER_ATTRIBUTE_NAMES = {*sum(STRING_MONSTER_ATTRIBUTE_ALIASES.values(), ())} + +BOOL_MONSTER_ATTRIBUTE_ALIASES = {} +BOOL_MONSTER_ATTRIBUTE_NAMES = {*sum(BOOL_MONSTER_ATTRIBUTE_ALIASES.values(), ())} + +MONSTER_CLASS_ATTRIBUTES = { + *sum(NUMERIC_MONSTER_ATTRIBUTE_ALIASES, ()), + *sum(STRING_MONSTER_ATTRIBUTE_ALIASES, ()), + *sum(BOOL_MONSTER_ATTRIBUTE_NAMES, ()), +} +MONSTER_ATTR_ALIAS_TO_ATTR_MAP = {v: k for k, vs in { + **NUMERIC_MONSTER_ATTRIBUTE_ALIASES, + **STRING_MONSTER_ATTRIBUTE_ALIASES, + **BOOL_MONSTER_ATTRIBUTE_ALIASES, +}.items() for v in vs} + +KNOWN_AWOKEN_SKILL_TOKENS = {} +for skill, tokens in AWOKEN_SKILL_MAP.items(): + for token in list(tokens): + KNOWN_AWOKEN_SKILL_TOKENS[token] = skill diff --git a/dbcog/find_monster/tokens/__init__.py b/dbcog/find_monster/tokens/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dbcog/find_monster/tokens/find_monster_tokens.py b/dbcog/find_monster/tokens/find_monster_tokens.py new file mode 100644 index 00000000..f9cfcbe7 --- /dev/null +++ b/dbcog/find_monster/tokens/find_monster_tokens.py @@ -0,0 +1,563 @@ +from abc import ABC, abstractmethod +from collections import namedtuple +from fnmatch import fnmatch +from typing import Iterable, List, Mapping, NamedTuple, Optional, Set, TYPE_CHECKING, Tuple, Type, TypeVar, Union + +import regex as re +from Levenshtein import jaro, jaro_winkler +from itertools import chain +from tsutils.helper_classes import DummyObject +from tsutils.tsubaki.monster_header import MonsterHeader + +from dbcog.find_monster.token_mappings import AWAKENING_TOKENS, AWOKEN_SKILL_MAP, BOOL_MONSTER_ATTRIBUTE_ALIASES, \ + BOOL_MONSTER_ATTRIBUTE_NAMES, \ + NUMERIC_MONSTER_ATTRIBUTE_ALIASES, \ + NUMERIC_MONSTER_ATTRIBUTE_NAMES, \ + PLUS_AWOKENSKILL_MAP, STRING_MONSTER_ATTRIBUTE_ALIASES, STRING_MONSTER_ATTRIBUTE_NAMES +from dbcog.models.enum_types import Attribute, AwokenSkills +from dbcog.models.monster_model import MonsterModel +from dbcog.monster_index import MonsterIndex + +if TYPE_CHECKING: + from dbcog import DBCog + +T = TypeVar("T") +MODIFIER_JW_DISTANCE = .95 +TOKEN_JW_DISTANCE = .8 +EPSILON = .001 + + +class MonsterMatch: + def __init__(self): + # The total score of the monster + self.score: float = 0 + + # Match data for all name tokens + self.name: Set[TokenMatch] = set() + + # Match data for all modifier tokens + self.mod: Set[TokenMatch] = set() + + def __repr__(self): + return str((self.score, [t[0] for t in self.name], [t[0] for t in self.mod])) + + +MatchMap = Mapping[MonsterModel, MonsterMatch] + + +class MonsterInfo(NamedTuple): + # The best matching monster + matched_monster: Optional[MonsterModel] + + # How the user tokens matched each monster + monster_matches: MatchMap + + # All monsters that were not rejected by the query + valid_monsters: Set[MonsterModel] + + +class MatchData(NamedTuple): + # The actual matching token + token: "QueryToken" + + # Type of name token + name_type: Optional[str] = None + + # Subquery result + subquery_result: Optional[MonsterModel] = None + + # Whether a attribute token matched the subattribute + subattr_match: Optional[bool] = None + + # Which or result was chosen + or_index: Optional[Tuple[int, str]] = None + + # Is negatable + can_negate: bool = True + + # From Evolution + from_evo: int = None + + def __repr__(self): + ret = [] + if self.name_type is not None: + ret.append(f"({self.name_type})") + if self.subquery_result is not None: + ret.append(f"[Subquery: {MonsterHeader.text_with_emoji(self.subquery_result)}]") + if self.subattr_match is not None: + ret.append("[Subattr]") + if self.or_index is not None: + ret.append(f"[Matched `{self.or_index[1]}`]") + if self.from_evo is not None: + ret.append(f"[From evo {self.from_evo}]") + return " ".join(ret) + + def __or__(self, other) -> "MatchData": + if other is None: + return self + kwargs = {} + for field in self._fields: + kwargs[field] = getattr(self, field) + if kwargs[field] is None: + kwargs[field] = getattr(other, field) + return MatchData(**kwargs) + + +class TokenMatch(NamedTuple): + # User token string + token: str + + # Monster token string + matched: str + + # Extra match data + match_data: MatchData + + +def regexlist(tokens): + return '(?:' + '|'.join(re.escape(t) for t in chain(tokens, ['$^'])) + ")" + + +class QueryToken(ABC): + def __init__(self, value: str, *, negated: bool = False, exact: bool = False): + self.value = value + self.negated = negated + self.exact = exact or negated + + @abstractmethod + async def matches(self, monster: MonsterModel, index: MonsterIndex) -> Tuple[float, Optional[TokenMatch]]: + ... + + def __eq__(self, other): + if isinstance(other, QueryToken): + return self.value == self.value + elif isinstance(other, str): + return self.value == other + + def __hash__(self): + return hash(self.value) + + def __repr__(self): + token = ("-" if self.negated else "") + (repr(self.value) if self.exact else self.value) + return f"{self.__class__.__name__}<{token}>" + + +class RegularToken(QueryToken): + async def matches(self, monster, index): + if len(self.value) < 6: + matched_token = self.value + else: + matched_token = max(index.modifiers[monster], key=lambda m: calc_ratio_modifier(self, m)) + + ratio = calc_ratio_modifier(matched_token, self.value) + if matched_token not in index.modifiers[monster] or ratio <= MODIFIER_JW_DISTANCE: + return 0.0, None + + return ratio, TokenMatch(self.value, matched_token, MatchData(self)) + + +class SpecialToken(QueryToken): + RE_MATCH: str + + def __init__(self, value, *, negated=False, exact=False, dbcog: "DBCog"): + self.dbcog = dbcog + super().__init__(value, negated=negated, exact=exact) + + async def special_matches(self, monster: MonsterModel) -> Tuple[Union[bool, float], MatchData]: + return True, MatchData(self) + + async def matches(self, monster, index): + mult, data = await self.special_matches(monster) + if self.value in index.modifiers[monster] and mult <= 1: + # If the full value is actually a modifier + return 1.0, TokenMatch(self.value, self.value, MatchData(self)) + return mult, TokenMatch(self.value, '', data) + + async def prepare(self): + return self + + +class MultipleAwakeningToken(SpecialToken): + RE_MATCH = rf"(\d+)-(sa-)?-?({regexlist(AWAKENING_TOKENS)})" + + def __init__(self, fullvalue, *, negated=False, exact=False, dbcog): + count, sa, awo = re.fullmatch(self.RE_MATCH, fullvalue).groups() + self.minimum_count = int(count) + self.allows_super_awakenings = bool(sa) + self.awo = awo + super().__init__(fullvalue, negated=negated, exact=exact, dbcog=dbcog) + + async def special_matches(self, monster): + monster_total_awakenings_matching_token = 0 + for awakening in monster.awakenings: + if awakening.is_super and not self.allows_super_awakenings: + return False, MatchData(self) + + # Keep track of whether we matched this cycle for SA check at the end + matched = True + for awoken_skill in (self.dbcog.database.awoken_skill_map[aws.value] + for aws, tokens in AWOKEN_SKILL_MAP.items() + if self.awo in tokens): + if (equivalence := PLUS_AWOKENSKILL_MAP.get(AwokenSkills(awakening.awoken_skill_id))) \ + and equivalence.awoken_skill.value == awoken_skill.awoken_skill_id: + monster_total_awakenings_matching_token += equivalence.value + break + elif awoken_skill == awakening: + monster_total_awakenings_matching_token += 1 + break + else: + matched = False + + if monster_total_awakenings_matching_token >= self.minimum_count: + return True, MatchData(self) + + # If we already matched an SA and didn't return True, fail immediately. + # We only allow one SA to count towards the total for each MultipleAwakeningToken + if awakening.is_super and matched: + return False, MatchData(self) + return False, MatchData(self) + + +class MonsterAttributeNumeric(SpecialToken): + RE_MATCH = rf"({regexlist(NUMERIC_MONSTER_ATTRIBUTE_NAMES)}):([<>=]+)?(\d+)([kmb])?" + + def __init__(self, fullvalue, *, negated=False, exact=False, dbcog): + c_attr, ineq, value, mult = re.fullmatch(self.RE_MATCH, fullvalue.lower()).groups() + self.monster_class_attributes = {ats for ats, aliases in NUMERIC_MONSTER_ATTRIBUTE_ALIASES.items() + if c_attr in aliases}.pop() + self.operator = ineq or "=" + self.rhs = int(value) * (1e9 if mult == 'b' else 1e6 if mult == 'm' else 1e3 if mult == 'k' else 1) + super().__init__(fullvalue, negated=negated, exact=exact, dbcog=dbcog) + + async def special_matches(self, monster): + for class_attrs in self.monster_class_attributes: + val: MonsterModel = monster + for class_attr in class_attrs: + val: int = getattr(val, class_attr, None) + if val is None: + continue + # Test each character of the equality operator because I don't want to use the eval function here + if "<" in self.operator and val < self.rhs: + return True, MatchData(self) + if ">" in self.operator and val > self.rhs: + return True, MatchData(self) + if "=" in self.operator and val == self.rhs: + return True, MatchData(self) + return False, MatchData(self) + + +class MonsterAttributeString(SpecialToken): + RE_MATCH = rf"({regexlist(STRING_MONSTER_ATTRIBUTE_NAMES)}):([=rg]?)([\"']?)(.+)\3" + + def __init__(self, fullvalue, *, negated=False, exact=False, dbcog): + c_attr, match, _, string = re.fullmatch(self.RE_MATCH, fullvalue.lower()).groups() + self.monster_class_attributes = {ats for ats, aliases in STRING_MONSTER_ATTRIBUTE_ALIASES.items() + if c_attr in aliases}.pop() + self.match = match + self.string = string + super().__init__(fullvalue, negated=negated, exact=exact, dbcog=dbcog) + + async def special_matches(self, monster): + max_score = 0 + for class_attrs in self.monster_class_attributes: + val: MonsterModel = monster + for class_attr in class_attrs: + val: str = getattr(val, class_attr, None) + if val is None: + continue + val: str = val.lower() + if self.match == "=" and val == self.string: # Exact match + return True, MatchData(self) + elif self.match == "r" and bool(re.search(self.string, val)): # Regex match + return True, MatchData(self) + elif self.match == "g" and fnmatch(val, '*' + self.string + '*'): # Glob match + return True, MatchData(self) + elif self.string in val: + return True, MatchData(self) + max_score = max(max_score, jaro(self.string, val)) + if max_score >= TOKEN_JW_DISTANCE: + return max_score, MatchData(self) + return False, MatchData(self) + + +class MonsterAttributeBool(SpecialToken): + RE_MATCH = rf"({regexlist(BOOL_MONSTER_ATTRIBUTE_NAMES)}):(.+)" + + def __init__(self, fullvalue, *, negated=False, exact=False, dbcog): + c_attr, raw_bool_value = re.fullmatch(self.RE_MATCH, fullvalue.lower()).groups() + self.monster_class_attributes = {ats for ats, aliases in BOOL_MONSTER_ATTRIBUTE_ALIASES.items() + if c_attr in aliases}.pop() + self.bool_value = raw_bool_value not in ('0', 'false', 'no') + super().__init__(fullvalue, negated=negated, exact=exact, dbcog=dbcog) + + async def special_matches(self, monster): + for class_attrs in self.monster_class_attributes: + val: MonsterModel = monster + for class_attr in class_attrs: + val: str = getattr(val, class_attr, None) + if val is None: + continue + if val == self.bool_value: + return True, MatchData(self) + return False, MatchData(self) + + +class SubqueryToken(SpecialToken): + label: str + + def __init__(self, fullvalue, subquery, *, negated=False, exact=False, dbcog): + self.subquery = subquery + self.sub_matches = None + self.valid_monsters = None + self.max_score = None + + super().__init__(fullvalue, negated=negated, exact=exact, dbcog=dbcog) + + async def prepare(self): + m_info, _ = await self.dbcog.find_monster_debug(self.subquery) + pruned_m_info = await prune_results_subquery(m_info) + self.sub_matches = pruned_m_info.monster_matches + self.valid_monsters = pruned_m_info.valid_monsters + self.max_score = max((self.sub_matches[mon].score for mon in self.valid_monsters), default=0) + + @abstractmethod + def get_matching_monsters(self, monster: MonsterModel) -> Iterable[MonsterModel]: + ... + + async def special_matches(self, monster): + mons = self.valid_monsters.intersection(self.get_matching_monsters(monster)) + if not mons: + return False, MatchData(self) + matched, score = max(((mon, self.sub_matches.get(mon, DummyObject(score=0)).score) for mon in mons), + key=lambda x: x[1]) + return normalize_score(score, self.max_score), MatchData(self, subquery_result=matched) + + +SQT = TypeVar("SQT", bound=Type[SubqueryToken]) + + +def make_tree_based(subquery_token: SQT, name: str, regex: str) -> SQT: + class _TreeToken(subquery_token): + RE_MATCH = regex + + def get_matching_monsters(self, monster): + return chain(*(super(_TreeToken, self).get_matching_monsters(nevo) + for nevo in self.dbcog.database.graph.get_all_prev_evolutions(monster))) + + _TreeToken.__name__ = name + return _TreeToken + + +class SelfHasMaterial(SubqueryToken): + label = 'hasmat' + RE_MATCH = (rf"(?:selfha[sz]mat|ha[sz]matself):([\"']?)(.+)\1") + + def __init__(self, fullvalue, *, negated=False, exact=False, dbcog): + _, subquery = re.fullmatch(self.RE_MATCH, fullvalue.lower()).groups() + super().__init__(fullvalue, subquery, negated=negated, exact=exact, dbcog=dbcog) + + def get_matching_monsters(self, monster): + return self.dbcog.database.graph.evo_mats(self.dbcog.database.graph.evo_gem_monster(monster) or monster) + + +HasMaterial = make_tree_based( + SelfHasMaterial, + "HasMaterial", + rf"ha[sz]mat:([\"']?)(.+)\1" +) + + +class SeriesOf(SubqueryToken): + label = 'seriesof' + RE_MATCH = r"seriesof:([\"']?)(.+)\1" + + def __init__(self, fullvalue, *, negated=False, exact=False, dbcog): + _, subquery = re.fullmatch(self.RE_MATCH, fullvalue.lower()).groups() + super().__init__(fullvalue, subquery, negated=negated, exact=exact, dbcog=dbcog) + + def get_matching_monsters(self, monster): + return self.dbcog.database.get_monsters_where(lambda m: m.series_id == monster.series_id, + server=monster.server_priority, + cache_key=('series_id', monster.series_id)) + + +class SameEvoTree(SubqueryToken): + label = 'sametree' + RE_MATCH = r"\((.+)\)" + + def __init__(self, fullvalue, *, negated=False, exact=False, dbcog): + subquery, = re.fullmatch(self.RE_MATCH, fullvalue.lower()).groups() + super().__init__(fullvalue, subquery, negated=negated, exact=exact, dbcog=dbcog) + + def get_matching_monsters(self, monster): + return self.dbcog.database.graph.get_alt_monsters(monster) \ + + list(self.dbcog.database.graph.get_monsters_with_same_id(monster)) + + +class AttributeToken(SpecialToken): + RE_MATCH = r"[rbgldx]|red|fire|blue|water|green|wood|light|yellow|dark|purple|nil|none|null|white" + + def __init__(self, fullvalue, *, negated=False, exact=False, dbcog): + if fullvalue in ('r', 'red', 'fire'): + self.attr = Attribute.Fire + elif fullvalue in ('b', 'blue', 'water'): + self.attr = Attribute.Water + elif fullvalue in ('g', 'green', 'wood'): + self.attr = Attribute.Wood + elif fullvalue in ('l', 'light', 'yellow'): + self.attr = Attribute.Light + elif fullvalue in ('d', 'dark', 'purple'): + self.attr = Attribute.Dark + else: + self.attr = Attribute.Nil + + super().__init__(fullvalue, negated=negated, exact=exact, dbcog=dbcog) + + async def special_matches(self, monster): + if monster.attr1 == self.attr: + return True, MatchData(self) + if monster.attr2 == self.attr: + return 1 - EPSILON, MatchData(self, subattr_match=True, can_negate=False) + return False, MatchData(self) + + +class OrToken(SpecialToken): + RE_MATCH = r"\[.+ .+\]" + + def __init__(self, fullvalue, *, negated=False, exact=False, dbcog): + self.subqueries = [s.replace('\0', ' ') + for s in re.sub(r'"[^"]+"|\'[^\']+\'', + lambda m: m.group(0).replace(' ', '\0')[1:-1], + fullvalue[1:-1]).split()] + + self.tokens: Optional[List[QueryToken]] = None + self.results = [] + + super().__init__(fullvalue, negated=negated, exact=exact, dbcog=dbcog) + + async def prepare(self): + Subquery = namedtuple("Subquery", 'sub_matches valid_monsters max_score') + for subquery in self.subqueries: + m_info, _ = await self.dbcog.find_monster_debug(subquery) + self.results.append(Subquery( + m_info.monster_matches, + m_info.valid_monsters, + max((m_info.monster_matches[mon].score for mon in m_info.valid_monsters), default=0) + )) + + async def special_matches(self, monster): + best_score = 0 + best_data = MatchData(self) + for c, subquery in enumerate(self.results): + mons = subquery.valid_monsters + if monster not in subquery.valid_monsters: + continue + score = subquery.sub_matches.get(monster, DummyObject(score=0)).score + if best_score < normalize_score(score, subquery.max_score): + best_score = normalize_score(score, subquery.max_score) + best_data = MatchData(self, or_index=(c, self.subqueries[c])) + return best_score, best_data + + +class IDRangeToken(SpecialToken): + RE_MATCH = r"(\d+?)-(\d*)" + + def __init__(self, fullvalue, *, negated=False, exact=False, dbcog): + lbound, rbound = re.fullmatch(self.RE_MATCH, fullvalue.lower()).groups() + self.lower_bound = int(lbound) + self.upper_bound = int(rbound or 9e9) + + super().__init__(fullvalue, negated=negated, exact=exact, dbcog=dbcog) + + async def special_matches(self, monster): + return self.lower_bound <= monster.monster_id <= self.upper_bound, MatchData(self) + + +def calc_ratio_modifier(s1: Union[QueryToken, str], s2: str, prefix_weight: float = .05) -> float: + """Calculate the modifier distance between two tokens""" + if isinstance(s1, QueryToken): + if s1.exact: + return 1.0 if s1.value == s2 else 0.0 + s1 = s1.value + + return jaro_winkler(s1, s2, prefix_weight) + + +def calc_ratio_name(token: Union[QueryToken, str], full_word: str, prefix_weight: float, index: MonsterIndex) -> float: + """Calculate the name distance between two tokens""" + string = token.value if isinstance(token, QueryToken) else token + + mw = index.mwt_to_len[full_word] != 1 + jw = jaro_winkler(string, full_word, prefix_weight) + + if string != full_word: + if isinstance(token, QueryToken) and token.exact: + return 0.0 + if string.isdigit() and full_word.isdigit(): + return 0.0 + + if full_word == string: + score = 1.0 + elif len(string) >= 3 and full_word.startswith(string): + score = .995 + if mw and jw < score: + return score + else: + score = jw + + if mw: + score = score ** 10 * index.mwt_to_len[full_word] + + return score + + +async def string_to_token(string: str, dbcog: "DBCog") -> QueryToken: + if (negated := string.startswith('-')): + string = string.lstrip('-') + if (exact := bool(re.fullmatch(r'".+"', string))): + string = string[1:-1] + for special in SPECIAL_TOKEN_TYPES: + if re.fullmatch(special.RE_MATCH, string): + token = special(string, negated=negated, exact=exact, dbcog=dbcog) + await token.prepare() + return token + return RegularToken(string, negated=negated, exact=exact) + + +async def prune_results_subquery(monster_info: "MonsterInfo") -> "MonsterInfo": + exacts = set() + for monster in monster_info.valid_monsters: + matched = False + for token in monster_info.monster_matches[monster].name: + if token.match_data.name_type != "fluff": + if token.matched == token.token: + matched = True + else: + break + else: + if matched: + exacts.add(monster) + return MonsterInfo(monster_info.matched_monster, + monster_info.monster_matches, + exacts or monster_info.valid_monsters) + + +def normalize_score(score: float, max_score: float) -> float: + if max_score == 0: + return 1.0 + return score / max_score + + +SPECIAL_TOKEN_TYPES: Set[Type[SpecialToken]] = { + MultipleAwakeningToken, + MonsterAttributeNumeric, + MonsterAttributeString, + MonsterAttributeBool, + SelfHasMaterial, + HasMaterial, + SeriesOf, + SameEvoTree, + AttributeToken, + OrToken, + IDRangeToken, +} diff --git a/dbcog/info.json b/dbcog/info.json new file mode 100644 index 00000000..d02d90d2 --- /dev/null +++ b/dbcog/info.json @@ -0,0 +1,32 @@ +{ + "author": [ + "The Tsubaki Bot Team" + ], + "description": "Very few commands, but loads all the monster data stored in Tsubaki.", + "end_user_data_statement": "ID Query Settings are stored in this cog.", + "install_msg": "Use [p]dbcog setdatafile if it's applicable to you.", + "short": "Loads monster data", + "min_bot_version": "3.4.0", + "max_bot_version": "0.0.0", + "min_python_version": [ + 3, + 8, + 1 + ], + "hidden": false, + "disabled": false, + "required_cogs": { + "globaladmin": "https://github.com/TsubakiBotPad/core-cogs" + }, + "requirements": [ + "python-Levenshtein-wheels", + "tsutils>=5.0.0", + "pytz", + "regex", + "romkan", + "networkx" + ], + "tags": [ + "PAD" + ] +} diff --git a/dbcog/models/__init__.py b/dbcog/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dbcog/models/active_skill_model.py b/dbcog/models/active_skill_model.py new file mode 100644 index 00000000..81930062 --- /dev/null +++ b/dbcog/models/active_skill_model.py @@ -0,0 +1,112 @@ +from typing import Any, Dict, Sequence, Union + +from .base_model import BaseModel + + +class ActivePartModel(BaseModel): + def __init__(self, **kwargs): + self.active_part_id = kwargs['active_part_id'] + self.active_skill_type_id = kwargs['active_skill_type_id'] + + self.desc_ja = kwargs['desc_ja'] + self.desc_en = kwargs['desc_en'] + self.desc_ko = kwargs['desc_ko'] + self.desc_templated_ja = kwargs['desc_templated_ja'] + self.desc_templated_en = kwargs['desc_templated_en'] + self.desc_templated_ko = kwargs['desc_templated_ko'] + + self.desc = self.desc_en or self.desc_ja + self.desc_templated = self.desc_templated_en or self.desc_templated_ja + + def to_dict(self): + return { + 'active_part_id': self.active_part_id, + 'desc_ja': self.desc_ja, + 'desc_en': self.desc_en, + } + + +class ActiveSubskillModel(BaseModel): + def __init__(self, *, active_parts: Sequence[Union[ActivePartModel, Dict[str, Any]]], **kwargs): + if active_parts and isinstance(active_parts[0], dict): + active_parts = [ActivePartModel(**ap) for ap in active_parts] + + self.active_subskill_id = kwargs['active_subskill_id'] + + self.name_ja = kwargs['name_ja'] + self.name_en = kwargs['name_en'] + self.name_ko = kwargs['name_ko'] + self.desc_ja = kwargs['desc_ja'] + self.desc_en = kwargs['desc_en'] + self.desc_ko = kwargs['desc_ko'] + self.desc_templated_ja = kwargs['desc_templated_ja'] + self.desc_templated_en = kwargs['desc_templated_en'] + self.desc_templated_ko = kwargs['desc_templated_ko'] + self.board_65 = kwargs['board_65'] + self.board_76 = kwargs['board_76'] + self.cooldown = kwargs['cooldown'] + + self.active_parts = active_parts + + self.name = self.name_en or self.name_ja + self.desc = self.desc_en or self.desc_ja + self.desc_templated = self.desc_templated_en or self.desc_templated_ja + + def to_dict(self): + return { + 'active_subskill_id': self.active_subskill_id, + 'desc_ja': self.desc_ja, + 'desc_en': self.desc_en, + } + + +class ActiveSkillModel(BaseModel): + def __init__(self, *, active_subskills: Sequence[Union[ActiveSubskillModel, Dict[str, Any]]], **kwargs): + if active_subskills and isinstance(active_subskills[0], dict): + active_subskills = [ActiveSubskillModel(**ass) for ass in active_subskills] + + self.active_skill_id = kwargs['active_skill_id'] + self.compound_skill_type_id = kwargs['compound_skill_type_id'] + + self.name_ja = kwargs['name_ja'] + self.name_en = kwargs['name_en'] + self.name_ko = kwargs['name_ko'] + self.desc_ja = kwargs['desc_ja'] + self.desc_en = kwargs['desc_en'] + self.desc_ko = kwargs['desc_ko'] + self.desc_templated_ja = kwargs['desc_templated_ja'] + self.desc_templated_en = kwargs['desc_templated_en'] + self.desc_templated_ko = kwargs['desc_templated_ko'] + self.desc_official_ja = kwargs['desc_official_ja'] + self.desc_official_en = kwargs['desc_official_en'] + self.desc_official_ko = kwargs['desc_official_ko'] + + self.cooldown_turns_max = kwargs['cooldown_turns_max'] + self.cooldown_turns_min = kwargs['cooldown_turns_min'] + + self.active_subskills = active_subskills + + self.name = self.name_en or self.name_ja + self.desc = self.desc_en or self.desc_ja + self.desc_templated = self.desc_templated_en or self.desc_templated_ja + + def to_dict(self): + return { + 'active_skill_id': self.active_skill_id, + 'name_ja': self.name_ja, + 'name_en': self.name_en, + } + + def __eq__(self, other): + if isinstance(other, ActiveSkillModel): + return self.active_skill_id == other.active_skill_id \ + and self.desc_en == other.desc_en \ + and self.cooldown_turns_max == other.cooldown_turns_max \ + and self.cooldown_turns_min == other.cooldown_turns_min + elif isinstance(other, ActiveSubskillModel): + return self.active_skill_id == other.active_subskill_id \ + and self.desc_en == other.desc_en + elif isinstance(other, ActivePartModel): + return self.active_skill_id == other.active_part_id \ + and self.desc_en == other.desc_en + return False diff --git a/dbcog/models/awakening_model.py b/dbcog/models/awakening_model.py new file mode 100644 index 00000000..c6eda8c1 --- /dev/null +++ b/dbcog/models/awakening_model.py @@ -0,0 +1,29 @@ +from .awoken_skill_model import AwokenSkillModel +from .base_model import BaseModel + + +class AwakeningModel(BaseModel): + """ + This class represents an awakening belonging to a monster, in contrast to AwokenSkillModel, which represents an "abstract" awoken skill. + """ + + def __init__(self, awoken_skill_model: AwokenSkillModel, **kwargs): + self.awakening_id: int = kwargs['awakening_id'] + self.monster_id: int = kwargs['monster_id'] + self.awoken_skill_id: int = kwargs['awoken_skill_id'] + self.is_super: int = kwargs['is_super'] + self.order_idx: int = kwargs['order_idx'] + self.awoken_skill: AwokenSkillModel = awoken_skill_model + self.name: str = self.awoken_skill.name + + def to_dict(self): + return { + 'awakening_id': self.awakening_id, + } + + def __eq__(self, other): + if isinstance(other, AwakeningModel): + return self.awoken_skill_id == other.awoken_skill_id and self.is_super == other.is_super + elif isinstance(other, AwokenSkillModel): + return self.awoken_skill == other + return False diff --git a/dbcog/models/awoken_skill_model.py b/dbcog/models/awoken_skill_model.py new file mode 100644 index 00000000..e507a277 --- /dev/null +++ b/dbcog/models/awoken_skill_model.py @@ -0,0 +1,31 @@ +from .base_model import BaseModel + + +class AwokenSkillModel(BaseModel): + def __init__(self, **kwargs): + self.awoken_skill_id: int = kwargs['awoken_skill_id'] + self.name_ja: str = kwargs['name_ja'] + self.name_en: str = kwargs['name_en'] + self.name_ko: str = kwargs['name_ko'] + self.name: str = self.name_en if self.name_en is not None else self.name_ja + self.desc_ja: str = kwargs['name_ja'] + self.desc_en: str = kwargs['desc_en'] + self.desc_ja: str = kwargs['desc_ja'] + self.desc_ko: str = kwargs['desc_ko'] + self.adj_hp: int = kwargs['adj_hp'] + self.adj_atk: int = kwargs['adj_atk'] + self.adj_rcv: int = kwargs['adj_rcv'] + + def to_dict(self): + return { + 'awoken_skill_id': self.awoken_skill_id, + 'name_en': self.name_en, + } + + def __eq__(self, other): + if hasattr(other, "awoken_skill"): + return self == other.awoken_skill + elif hasattr(other, "awoken_skill_id"): + return self.awoken_skill_id == other.awoken_skill_id + else: + return False diff --git a/dbcog/models/base_model.py b/dbcog/models/base_model.py new file mode 100644 index 00000000..47e99e47 --- /dev/null +++ b/dbcog/models/base_model.py @@ -0,0 +1,16 @@ +import json + + +class BaseModel(object): + + def to_dict(self): + raise NotImplementedError + + def to_string(self): + return self.__class__.__name__ + "<" + json.dumps(self.to_dict())[1:-1] + ">" + + def __repr__(self): + try: + return self.to_string() + except NotImplementedError: + return super().__repr__() diff --git a/dbcog/models/dungeon_model.py b/dbcog/models/dungeon_model.py new file mode 100644 index 00000000..6d0861bd --- /dev/null +++ b/dbcog/models/dungeon_model.py @@ -0,0 +1,65 @@ +from typing import List + +from .base_model import BaseModel +from .sub_dungeon_model import SubDungeonModel + + +class DungeonModel(BaseModel): + def __init__(self, sub_dungeon_models: List[SubDungeonModel] = None, **kwargs): + self.dungeon_id = kwargs['dungeon_id'] + self.name_ja = kwargs['name_ja'] + self.name_en = kwargs['name_en'] + self.clean_name_en = self._make_clean_name_en(self.name_en) + self.name_ko = kwargs['name_ko'] + + self.dungeon_type = kwargs['dungeon_type'] + self.sub_dungeons = sub_dungeon_models + + @staticmethod + def _make_clean_name_en(name): + if 'tamadra invades in some tech' in name.lower(): + return 'Latents invades some Techs & 20x +Eggs' + if '1.5x Bonus Pal Point in multiplay' in name: + name = '[Descends] 1.5x Pal Points in multiplay' + clean_name_map = { + 'No Continues': 'No Cont', + 'No Continue': 'No Cont', + 'Some Limited Time Dungeons': 'Some Guerrillas', + 'are added in': 'in', + '!': '', + 'Dragon Infestation': 'Dragons', + ' Infestation': 's', + 'Daily Descended Dungeon': 'Daily Descends', + 'Chance for ': '', + 'Jewel of the Spirit': 'Spirit Jewel', + ' & ': '/', + ' / ': '/', + 'PAD Radar': 'PADR', + 'in normal dungeons': 'in normals', + 'Selected ': 'Some ', + 'Enhanced ': 'Enh ', + 'All Att. Req.': 'All Att.', + 'Extreme King Metal Dragon': 'Extreme KMD', + 'Golden Mound-Tricolor [Fr/Wt/Wd Only]': 'Golden Mound', + 'Gods-Awakening Materials Descended': "Awoken Mats", + 'Orb move time 4 sec': '4s move time', + 'Awakening Materials Descended': 'Awkn Mats', + 'Awakening Materials': 'Awkn Mats', + "Star Treasure Thieves' Den": 'STTD', + 'Ruins of the Star Vault': 'Star Vault', + '-★6 or lower Enhanced': '', + 'Revenge of the ': '', + 'A Gathering of ': '', + '-Supergravity': '', + 'Fest Exclusive': 'GFE', + } + for find, replace in clean_name_map.items(): + name = name.replace(find, replace) + return name + + def to_dict(self): + return { + 'dungeon_id': self.dungeon_id, + 'name_ja': self.name_ja, + 'name_en': self.name_en + } diff --git a/dbcog/models/encounter_model.py b/dbcog/models/encounter_model.py new file mode 100644 index 00000000..a5b05ab5 --- /dev/null +++ b/dbcog/models/encounter_model.py @@ -0,0 +1,33 @@ +from .base_model import BaseModel +from .enemy_data_model import EnemyDataModel + + +class EncounterModel(BaseModel): + def __init__(self, enemy_data_model: EnemyDataModel = None, **kwargs): + self.encounter_id = kwargs['encounter_id'] + self.sub_dungeon_id = kwargs['sub_dungeon_id'] + self.enemy_id = kwargs['enemy_id'] + self.monster_id = kwargs['monster_id'] + self.stage = kwargs['stage'] + self.amount = kwargs['amount'] + self.turns = kwargs['turns'] + self.level = kwargs['level'] + self.hp = kwargs['hp'] + self.atk = kwargs['atk'] + self.defense = kwargs['defense'] + self.enemy_data = enemy_data_model + + def to_dict(self): + return { + 'encounter_id': self.encounter_id, + 'sub_dungeon_id': self.sub_dungeon_id, + 'enemy_id': self.enemy_id, + 'monster_id': self.monster_id, + 'stage': self.stage, + 'amount': self.amount, + 'turns': self.turns, + 'level': self.level, + 'hp': self.hp, + 'atk': self.atk, + 'defense': self.defense + } diff --git a/dbcog/models/enemy_data_model.py b/dbcog/models/enemy_data_model.py new file mode 100644 index 00000000..ad8d9695 --- /dev/null +++ b/dbcog/models/enemy_data_model.py @@ -0,0 +1,13 @@ +from .base_model import BaseModel + + +class EnemyDataModel(BaseModel): + def __init__(self, **kwargs): + self.enemy_id = kwargs['enemy_id'] + self.behavior = kwargs['behavior'] + + def to_dict(self): + return { + 'enemy_id': self.enemy_id, + 'behavior': self.behavior, + } diff --git a/dbcog/models/enemy_skill_model.py b/dbcog/models/enemy_skill_model.py new file mode 100644 index 00000000..60b3427c --- /dev/null +++ b/dbcog/models/enemy_skill_model.py @@ -0,0 +1,23 @@ +from .base_model import BaseModel + + +class EnemySkillModel(BaseModel): + def __init__(self, **kwargs): + self.enemy_skill_id = kwargs['enemy_skill_id'] + self.name_en = kwargs['name_en'] + self.desc_en = kwargs['desc_en'] + self.desc_en_emoji = kwargs['desc_en_emoji'] + self.min_hits = kwargs['min_hits'] + self.max_hits = kwargs['max_hits'] + self.atk_mult = kwargs['atk_mult'] + + def to_dict(self): + return { + 'enemy_skill_id': self.enemy_skill_id, + 'name_en': self.name_en, + 'desc_en': self.desc_en, + 'desc_en_emoji': self.desc_en_emoji, + 'min_hits': self.min_hits, + 'max_hits': self.max_hits, + 'atk_mult': self.atk_mult + } diff --git a/dbcog/models/enum_types.py b/dbcog/models/enum_types.py new file mode 100644 index 00000000..0b2b529c --- /dev/null +++ b/dbcog/models/enum_types.py @@ -0,0 +1,165 @@ +from enum import Enum + +from tsutils.enums import Server + + +class Attribute(Enum): + """Standard 5 PAD colors in enum form. Values correspond to DadGuide values.""" + Fire = 0 + Water = 1 + Wood = 2 + Light = 3 + Dark = 4 + Unknown = 5 + Nil = 6 + + +class MonsterType(Enum): + Evolve = 0 + Balanced = 1 + Physical = 2 + Healer = 3 + Dragon = 4 + God = 5 + Attacker = 6 + Devil = 7 + Machine = 8 + Awoken = 12 + Enhance = 14 + Vendor = 15 + + +class InternalEvoType(Enum): + """Evo types unsupported by DadGuide.""" + Base = "Base" + Normal = "Normal" + Ultimate = "Ultimate" + Reincarnated = "Reincarnated" + Assist = "Assist" + Pixel = "Pixel" + SuperReincarnated = "Super Reincarnated" + + +class AwakeningRestrictedLatent(Enum): + """Latent awakenings with availability gated by having an awakening""" + UnmatchableClear = 606 + SpinnerClear = 607 + AbsorbPierce = 608 + + +def enum_or_none(enum, value, default=None): + if value is not None: + return enum(value) + else: + return default + + +DEFAULT_SERVER = Server.COMBINED +SERVERS = [Server.COMBINED, Server.NA] + + +class AwokenSkills(Enum): + ENHANCEDHP = 1 + ENHANCEDATK = 2 + ENHANCEDRCV = 3 + REDUCERED = 4 + REDUCEBLUE = 5 + REDUCEGREEN = 6 + REDUCELIGHT = 7 + REDUCEDARK = 8 + AUTOHEAL = 9 + BINDRES = 10 + BLINDRES = 11 + JAMMERRES = 12 + POISONRES = 13 + ENHANCEDRED = 14 + ENHANCEDBLUE = 15 + ENHANCEDGREEN = 16 + ENHANCEDLIGHT = 17 + ENHANCEDDARK = 18 + EXTMOVE = 19 + BINDRECOVERY = 20 + SKILLBOOST = 21 + REDROW = 22 + BLUEROW = 23 + GREENROW = 24 + LIGHTROW = 25 + DARKROW = 26 + TPA = 27 + SKILLBINDRES = 28 + ENHANCEDHEAL = 29 + MULTIBOOST = 30 + DRAGONKILLER = 31 + GODKILLER = 32 + DEVILKILLER = 33 + MACHINEKILLER = 34 + BALANCEDKILLER = 35 + ATTACKERKILLER = 36 + PHYSICALKILLER = 37 + HEALERKILLER = 38 + EVOMATKILLER = 39 + AWOKENKILLER = 40 + FODDERKILLER = 41 + REDEEMKILLER = 42 + ENHCOMBO7C = 43 + GUARDBREAK = 44 + FUA = 45 + ENHTEAMHP = 46 + ENHTEAMRCV = 47 + VDP = 48 + EQUIP = 49 + SUPERFUA = 50 + SKILLCHARGE = 51 + UNBINDABLE = 52 + EXTMOVEPLUS = 53 + CLOUDRESIST = 54 + TAPERESIST = 55 + SKILLBOOSTPLUS = 56 + HP80ORMORE = 57 + HP50ORLESS = 58 + ELSHIELD = 59 + ELATTACK = 60 + ENHCOMBO10C = 61 + COMBOORB = 62 + VOICE = 63 + DUNGEONBONUS = 64 + REDUCEDHP = 65 + REDUCEDATK = 66 + REDUCEDRCV = 67 + UNBLINDABLE = 68 + UNJAMMABLE = 69 + UNPOISONABLE = 70 + JAMMERBLESSING = 71 + POISONBLESSING = 72 + REDCOMBOCOUNT = 73 + BLUECOMBOCOUNT = 74 + GREENCOMBOCOUNT = 75 + LIGHTCOMBOCOUNT = 76 + DARKCOMBOCOUNT = 77 + CROSSATTACK = 78 + ATTR3BOOST = 79 + ATTR4BOOST = 80 + ATTR5BOOST = 81 + BLOBBOOST = 82 + ADDTYPEDRAGON = 83 + ADDTYPEGOD = 84 + ADDTYPEDEVIL = 85 + ADDTYPEMACHINE = 86 + ADDTYPEBALANCED = 87 + ADDTYPEATTACKER = 88 + ADDTYPEPHYSICAL = 89 + ADDTYPEHEALER = 90 + SUBATTRRED = 91 + SUBATTRBLUE = 92 + SUBATTRGREEN = 93 + SUBATTRLIGHT = 94 + SUBATTRDARK = 95 + TPAPLUS = 96 + SKILLCHARGEPLUS = 97 + AUTOHEALPLUS = 98 + ENHANCEDREDPLUS = 99 + ENHANCEDBLUEPLUS = 100 + ENHANCEDGREENPLUS = 101 + ENHANCEDLIGHTPLUS = 102 + ENHANCEDDARKPLUS = 103 + ENHANCEDHEALPLUS = 104 diff --git a/dbcog/models/evolution_model.py b/dbcog/models/evolution_model.py new file mode 100644 index 00000000..f26ddfa9 --- /dev/null +++ b/dbcog/models/evolution_model.py @@ -0,0 +1,26 @@ +from .base_model import BaseModel + + +class EvolutionModel(BaseModel): + def __init__(self, **kwargs): + self.evolution_type = kwargs['evolution_type'] + self.reversible = bool(kwargs['reversible']) + self.from_id = kwargs['from_id'] + self.to_id = kwargs['to_id'] + self.mat_1_id = kwargs['mat_1_id'] + self.mat_2_id = kwargs['mat_2_id'] + self.mat_3_id = kwargs['mat_3_id'] + self.mat_4_id = kwargs['mat_4_id'] + self.mat_5_id = kwargs['mat_5_id'] + self.mats = list(filter(None, [self.mat_1_id, self.mat_2_id, self.mat_3_id, self.mat_4_id, self.mat_5_id])) + self.is_pixel = self.mat_1_id == 3826 + self.is_super_reincarnated = self.mat_1_id == 5077 + self.is_assist = self.mat_1_id == 3911 + self.tstamp = kwargs['tstamp'] + + def to_dict(self): + return { + 'evolution_type': self.evolution_type, + 'from_id': self.from_id, + 'to_id': self.to_id, + } diff --git a/dbcog/models/exchange_model.py b/dbcog/models/exchange_model.py new file mode 100644 index 00000000..038c3926 --- /dev/null +++ b/dbcog/models/exchange_model.py @@ -0,0 +1,29 @@ +import re +from datetime import datetime + +from tsutils.enums import Server + +from .base_model import BaseModel + + +class ExchangeModel(BaseModel): + def __init__(self, **kwargs): + self.exchange_id = kwargs['exchange_id'] + self.trade_id = kwargs['trade_id'] + self.server = Server(('JP', 'NA', 'KR')[kwargs['server_id']]) + self.target_monster_id = kwargs['target_monster_id'] + self.required_monster_ids = [int(i) for i in re.findall(r'\d+', kwargs['required_monster_ids'])] + self.required_count = kwargs['required_count'] + self.start_timestamp = datetime.fromtimestamp(kwargs['start_timestamp']) + self.end_timestamp = datetime.fromtimestamp(kwargs['end_timestamp']) + self.permanent = bool(kwargs['permanent']) + self.menu_idx = kwargs['menu_idx'] + self.order_idx = kwargs['order_idx'] + self.flags = kwargs['flags'] + self.tstamp = datetime.fromtimestamp(kwargs['tstamp']) + + def to_dict(self): + return { + 'from': self.required_monster_ids, + 'to': self.target_monster_id, + } diff --git a/dbcog/models/leader_skill_model.py b/dbcog/models/leader_skill_model.py new file mode 100644 index 00000000..d9533618 --- /dev/null +++ b/dbcog/models/leader_skill_model.py @@ -0,0 +1,61 @@ +import re +from typing import List + +from .base_model import BaseModel + + +class LeaderSkillModel(BaseModel): + def __init__(self, **kwargs): + self.leader_skill_id: int = kwargs['leader_skill_id'] + self.name_ja: str = kwargs['name_ja'] + self.name_en: str = kwargs['name_en'] + self.name_ko: str = kwargs['name_ko'] + self.max_hp: int = kwargs['max_hp'] + self.max_atk: int = kwargs['max_atk'] + self.max_rcv: int = kwargs['max_rcv'] + self.max_shield: int = kwargs['max_shield'] + self.max_combos: int = kwargs['max_combos'] + self.bonus_damage: int = kwargs['bonus_damage'] + self.mult_bonus_damage: int = kwargs['mult_bonus_damage'] + self.extra_time: int = kwargs['extra_time'] + self.tags: List[int] = [int(tag) for tag in re.findall(r'\((\d+)\)', kwargs['tags'])] + self.desc_en: str = kwargs['desc_en'] + self.desc_ja: str = kwargs['desc_ja'] + self.desc_ko: str = kwargs['desc_ko'] + + @property + def data(self): + return (self.max_hp, + self.max_atk, + self.max_rcv, + self.max_shield, + self.max_combos, + self.bonus_damage, + self.mult_bonus_damage, + self.extra_time) + + @property + def desc(self): + return self.desc_en or self.desc_ja + + @property + def name(self): + return self.name_en or self.name_ja + + @property + def is_7x6(self): + return 200 in self.tags + + def to_dict(self): + return { + 'leader_skill_id': self.leader_skill_id, + 'name_ja': self.name_ja, + 'name_en': self.name_en, + } + + def __eq__(self, other): + if isinstance(other, LeaderSkillModel): + return self.leader_skill_id == other.leader_skill_id \ + and self.data == other.data \ + and self.desc_en == other.desc_en + return False diff --git a/dbcog/models/monster/__init__.py b/dbcog/models/monster/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dbcog/models/monster/monster_difference.py b/dbcog/models/monster/monster_difference.py new file mode 100644 index 00000000..140b6435 --- /dev/null +++ b/dbcog/models/monster/monster_difference.py @@ -0,0 +1,80 @@ +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from dbcog.models.monster_model import MonsterModel + + +class MonsterDifference: + __slots__ = 'types', 'awakenings', 'leader_skill', 'active_skill', 'rarity', \ + 'sell_gold', 'is_inheritable', 'cost', 'exp', 'fodder_exp', 'level', \ + 'limit_mult', 'latent_slots', 'stat_values', 'existance' + + def __init__(self, types=False, awakenings=False, leader_skill=False, active_skill=False, rarity=False, + sell_gold=False, is_inheritable=False, cost=False, exp=False, fodder_exp=False, level=False, + limit_mult=False, latent_slots=False, stat_values=False, existance=False): + self.types = types + self.awakenings = awakenings + self.leader_skill = leader_skill + self.active_skill = active_skill + self.rarity = rarity + self.sell_gold = sell_gold + self.is_inheritable = is_inheritable + self.cost = cost + self.exp = exp + self.fodder_exp = fodder_exp + self.level = level + self.limit_mult = limit_mult + self.latent_slots = latent_slots + self.stat_values = stat_values + self.existance = existance + + @classmethod + def from_monsters(cls, monster1: "MonsterModel", monster2: Optional["MonsterModel"]): + if monster2 is None: + return cls(existance=True) + + return cls( + monster1.types != monster2.types, + monster1.awakenings != monster2.awakenings, + monster1.leader_skill != monster2.leader_skill, + monster1.active_skill != monster2.active_skill, + monster1.rarity != monster2.rarity, + monster1.sell_gold != monster2.sell_gold, + monster1.is_inheritable != monster2.is_inheritable, + monster1.cost != monster2.cost, + monster1.exp != monster2.exp, + monster1.fodder_exp != monster2.fodder_exp, + monster1.level != monster2.level, + monster1.limit_mult != monster2.limit_mult, + monster1.latent_slots != monster2.latent_slots, + monster1.stat_values != monster2.stat_values, + False) + + @classmethod + def from_int(cls, value: int): + return cls(*(value & 1 << exp for exp in range(len(cls.__slots__)))) + + @property + def value(self): + return (self.types << 0 + | self.awakenings << 1 + | self.leader_skill << 2 + | self.active_skill << 3 + | self.rarity << 4 + | self.sell_gold << 5 + | self.is_inheritable << 6 + | self.cost << 7 + | self.exp << 8 + | self.fodder_exp << 9 + | self.level << 10 + | self.limit_mult << 11 + | self.latent_slots << 12 + | self.stat_values << 13 + | self.existance << 14) + + def __bool__(self): + return bool(self.value) + + def __repr__(self): + vals = ', '.join(f"{value}=True" for value in self.__slots__ if getattr(self, value)) + return f"MonsterDifference({vals or 'False'})" diff --git a/dbcog/models/monster_model.py b/dbcog/models/monster_model.py new file mode 100644 index 00000000..36b7e425 --- /dev/null +++ b/dbcog/models/monster_model.py @@ -0,0 +1,211 @@ +import re +from datetime import datetime +from typing import Set, Optional, List, Dict, Any + +import romkan +from tsutils.enums import Server +from tsutils.formatting import contains_ja, rmdiacritics + +from .active_skill_model import ActiveSkillModel +from .awakening_model import AwakeningModel +from .base_model import BaseModel +from .enum_types import Attribute, AwakeningRestrictedLatent, MonsterType, enum_or_none +from .leader_skill_model import LeaderSkillModel +from .monster.monster_difference import MonsterDifference +from .monster_stats import monster_stats +from .series_model import SeriesModel + + +class MonsterModel(BaseModel): + def __init__(self, **m): + super().__init__() + self.monster_id: int = m['monster_id'] + self.monster_no_jp: int = m['monster_no_jp'] + self.monster_no_na: int = m['monster_no_na'] + self.monster_no_kr: int = m['monster_no_kr'] + self.base_evo_id: int = m['base_evo_id'] + + self.on_jp: bool = m['on_jp'] + self.on_na: bool = m['on_na'] + self.on_kr: bool = m['on_kr'] + + self.awakenings: List[AwakeningModel] = sorted(m['awakenings'], key=lambda a: a.order_idx) + self.superawakening_count: int = sum(int(a.is_super) for a in self.awakenings) + self.leader_skill: LeaderSkillModel = m['leader_skill'] + self.leader_skill_id: int = self.leader_skill.leader_skill_id if self.leader_skill else None + self.active_skill: ActiveSkillModel = m['active_skill'] + self.active_skill_id: int = self.active_skill.active_skill_id if self.active_skill else None + + self.series: SeriesModel = m['series'] + self.all_series: Set[SeriesModel] = m['all_series'] + self.series_id: int = m['series_id'] + self.group_id: int = m['group_id'] + self.collab_id: int = m['collab_id'] + self.name_ja: str = m['name_ja'] + self.name_ko: str = m['name_ko'] + self.name_en: str = m['name_en'] + self.unoverridden_name_en: str = m['name_en'] + self.roma_subname: Optional[str] = None + if self.name_en == self.name_ja and not self.on_na: + self.roma_subname = self.make_roma_subname(self.name_ja) + else: + # Remove annoying stuff from NA names, like Jörmungandr + self.name_en = rmdiacritics(self.name_en) + + self.name_en_override: Optional[str] = None + # If the NA and JP names are the same, chances are the card isn't released in both reigions, and + # if we have an override, chances are it's JP only because we wouldn't need one for an NA only card. + # We aren't using the on_na flag here because that flag sucks and has false positives all the time + if self.name_en == self.name_ja: + self.name_en_override = m['name_en_override'] + if not m['name_is_translation']: + self.name_en_override = m['name_en_override'] + + self.name_en = (self.name_en_override or self.name_en).strip() + + self.type1: Optional[MonsterType] = enum_or_none(MonsterType, m['type_1_id']) + self.type2: Optional[MonsterType] = enum_or_none(MonsterType, m['type_2_id']) + self.type3: Optional[MonsterType] = enum_or_none(MonsterType, m['type_3_id']) + self.types: List[MonsterType] = list(filter(None, [self.type1, self.type2, self.type3])) + + self.rarity: int = m['rarity'] + self.is_farmable: bool = m['is_farmable'] + self.in_rem: bool = m['in_rem'] + self.in_pem: bool = m['in_pem'] + self.in_vem: bool = m['in_vem'] + self.in_mpshop: bool = m['buy_mp'] is not None + self.buy_mp: int = m['buy_mp'] + self.sell_gold: int = m['sell_gold'] + self.sell_mp: int = m['sell_mp'] + self.reg_date: datetime = m['reg_date'] + self.attr1: Optional[Attribute] = enum_or_none(Attribute, m['attribute_1_id'], Attribute.Nil) + self.attr2: Optional[Attribute] = enum_or_none(Attribute, m['attribute_2_id'], Attribute.Nil) + self.is_equip: bool = any([x.awoken_skill_id == 49 for x in self.awakenings]) + self.is_inheritable: bool = m['is_inheritable'] + self.is_stackable: bool = m['is_stackable'] + self.evo_gem_id: Optional[int] = m['evo_gem_id'] + self.orb_skin_id: Optional[int] = m['orb_skin_id'] + self.bgm_id: Optional[int] = m['bgm_id'] + self.cost: int = m['cost'] + self.exp: int = m['exp'] + self.fodder_exp: int = m['fodder_exp'] + self.level: int = m['level'] + self.limit_mult: int = m['limit_mult'] + self.latent_slots: int = m['latent_slots'] + + self.hp_max: int = m['hp_max'] + self.hp_min: int = m['hp_min'] + self.hp_scale: int = m['hp_scale'] + self.atk_max: int = m['atk_max'] + self.atk_min: int = m['atk_min'] + self.atk_scale: int = m['atk_scale'] + self.rcv_max: int = m['rcv_max'] + self.rcv_min: int = m['rcv_min'] + self.rcv_scale: int = m['rcv_scale'] + self.stat_values: Dict[Any] = { + 'hp': {'min': self.hp_min, 'max': self.hp_max, 'scale': self.hp_scale}, + 'atk': {'min': self.atk_min, 'max': self.atk_max, 'scale': self.atk_scale}, + 'rcv': {'min': self.rcv_min, 'max': self.rcv_max, 'scale': self.rcv_scale} + } + + self.voice_id_jp: Optional[int] = m['voice_id_jp'] + self.voice_id_na: Optional[int] = m['voice_id_na'] + + self.has_animation: bool = m['has_animation'] + self.has_hqimage: bool = m['has_hqimage'] + + self.server_priority = m['server_priority'] + + @property + def killers(self): + type_to_killers_map = { + MonsterType.God: ['Devil'], + MonsterType.Devil: ['God'], + MonsterType.Machine: ['God', 'Balanced'], + MonsterType.Dragon: ['Machine', 'Healer'], + MonsterType.Physical: ['Machine', 'Healer'], + MonsterType.Attacker: ['Devil', 'Physical'], + MonsterType.Healer: ['Dragon', 'Attacker'], + } + if MonsterType.Balanced in self.types: + return ['Any'] + killers = set() + for t in self.types: + killers.update(type_to_killers_map.get(t, [])) + return sorted(killers) + + @property + def is_material(self): + return MonsterType.Evolve in self.types or MonsterType.Vendor in self.types or \ + MonsterType.Enhance in self.types or MonsterType.Awoken in self.types + + @property + def awakening_restricted_latents(self): + monster_awakening_to_allowed_latent_map = [ + {27: AwakeningRestrictedLatent.UnmatchableClear}, # TPA + {20: AwakeningRestrictedLatent.SpinnerClear}, # Bind clear + {62: AwakeningRestrictedLatent.AbsorbPierce}, # Combo orb + ] + latents = [] + for row in monster_awakening_to_allowed_latent_map: + # iterate over one thing + for awakening in row.keys(): + if any([x.awoken_skill_id == awakening and not x.is_super for x in self.awakenings]): + latents.append(row[awakening]) + + return latents + + @property + def history_us(self): + return '[{}] New Added'.format(self.reg_date) + + @property + def full_damage_attr(self) -> Attribute: + if self.attr1 == Attribute.Nil: + return self.attr2 + return self.attr1 + + @property + def exp_curve(self) -> int: + if self.level == 1: + return 0 + return int(round(self.exp / ((self.level - 1) / 98) ** 2.5)) + + def exp_to_level(self, target_level: int) -> int: + return int(self.exp_curve * ((target_level - 1) / 98) ** 2.5) + + def awakening_count(self, awid): + return len([x for x in self.awakenings if x.awoken_skill_id == awid]) + + def stat(self, key, lv, plus=99, inherit=False, is_plus_297=True): + return monster_stats.stat(self, key, lv, plus=plus, inherit=inherit, is_plus_297=is_plus_297) + + def stats(self, lv=99, plus=0, inherit=False, multiplayer: bool = False): + return monster_stats.stats(self, lv, plus=plus, inherit=inherit, multiplayer=multiplayer) + + @staticmethod + def make_roma_subname(name_ja): + subname = re.sub(r'[=]', '', name_ja) + subname = re.sub(r'[「」]', '・', subname) + adjusted_subname = '' + for part in subname.split('・'): + roma_part = romkan.to_roma(part) + if part != roma_part and not contains_ja(roma_part): + adjusted_subname += ' ' + roma_part.strip('-') + return adjusted_subname.strip() + + def to_dict(self): + return { + 'monster_id': self.monster_id, + 'name_ja': self.name_ja, + 'name_en': self.name_en, + } + + def get_difference(self, other): + if other and self.monster_id != other.monster_id: + raise ValueError("You cannot get the difference of monsters with different ids.") + return MonsterDifference.from_monsters(self, other) + + def __repr__(self): + server_prefix = self.server_priority.name + " " if self.server_priority != Server.COMBINED else "" + return "Monster<{}{} ({})>".format(server_prefix, self.name_en, self.monster_id) diff --git a/dbcog/models/monster_stats.py b/dbcog/models/monster_stats.py new file mode 100644 index 00000000..4ca20b13 --- /dev/null +++ b/dbcog/models/monster_stats.py @@ -0,0 +1,143 @@ +from typing import Literal, TYPE_CHECKING +from dbcog.models.enum_types import AwokenSkills + +if TYPE_CHECKING: + from dbcog.models.monster_model import MonsterModel + +StatType = Literal['hp', 'atk', 'rcv'] + + +class MonsterStatModifierInput: + def __init__(self, num_hp=0, num_atk=0, num_rcv=0, num_hpplus=0, num_atkplus=0, num_rcvplus=0, num_hpplus2=0, + num_atkplus2=0, num_rcvplus2=0, num_all_stat=0, num_hp_awakening=0, num_atk_awakening=0, + num_rcv_awakening=0, num_voice_awakening=0): + self.num_hp = num_hp + self.num_atk = num_atk + self.num_rcv = num_rcv + self.num_hpplus = num_hpplus + self.num_atkplus = num_atkplus + self.num_rcvplus = num_rcvplus + self.num_hpplus2 = num_hpplus2 + self.num_atkplus2 = num_atkplus2 + self.num_rcvplus2 = num_rcvplus2 + self.num_all_stat = num_all_stat + self.num_hp_awakening = num_hp_awakening + self.num_atk_awakening = num_atk_awakening + self.num_rcv_awakening = num_rcv_awakening + self.num_voice_awakening = num_voice_awakening + + def get_latent_multiplier(self, key: StatType): + if key == 'hp': + return self.num_hp * 0.015 + self.num_hpplus * 0.045 + self.num_hpplus2 * 0.1 + self.num_all_stat * 0.03 + elif key == 'atk': + return self.num_atk * 0.01 + self.num_atkplus * 0.03 + self.num_atkplus2 * 0.08 + self.num_all_stat * 0.02 + elif key == 'rcv': + return self.num_rcv * 0.1 + self.num_rcvplus * 0.3 + self.num_rcvplus2 * 0.35 + self.num_all_stat * 0.2 + return 0 + + def get_awakening_addition(self, key: StatType): + if key == 'hp': + return 500 * self.num_hp_awakening + elif key == 'atk': + return 100 * self.num_atk_awakening + elif key == 'rcv': + return 200 * self.num_rcv_awakening + return 0 + + +class MonsterStats: + PLUS_DICT = {'hp': 10, 'atk': 5, 'rcv': 3} + LV_120_MULT_DICT = {'hp': 10, 'atk': 5, 'rcv': 5} + + def stat(self, monster_model, key: StatType, lv, *, plus=99, inherit=False, is_plus_297=True, + stat_latents: MonsterStatModifierInput = None, multiplayer=False, inherited_monster: "MonsterModel" = None, + inherited_monster_lvl=99): + # TODO: deal with atk-, rcv-, and hp- awakenings + # PAD rounds stats from the start before any calculations + s_val = round(self.base_stat(key, lv, monster_model)) + if stat_latents and not (monster_model.is_equip or inherit): + latents = s_val * stat_latents.get_latent_multiplier(key) + stat_awakenings = stat_latents.get_awakening_addition(key) + voice = s_val * stat_latents.num_voice_awakening * 0.1 + s_val += latents + stat_awakenings + voice + + # include plus calculations. todo: is there a way to do this without subtraction? + s_val += self.PLUS_DICT[key] * max(min(plus, 99), 0) + if inherit: + inherit_dict = {'hp': 0.10, 'atk': 0.05, 'rcv': 0.15} + if not is_plus_297: + s_val -= self.PLUS_DICT[key] * max(min(plus, 99), 0) + s_val *= inherit_dict[key] + + if inherited_monster: + # add base stats of inherit only if main atts are the same and not no-main-att + if monster_model.attr1.value == inherited_monster.attr1.value or monster_model.attr1.value == 6 or inherited_monster.attr1.value == 6: + # recursion is not possible because inherits do not have inherits on top of them + # inherit=True to calculate inherit stats and multiplayer=False in case the inherit has a multiboost awakening + s_val += round(self.stat(inherited_monster, key, inherited_monster_lvl, + inherit=True, multiplayer=False)) + # add bonus atk, hp, or rcv awakenings iff the inherit has awoken assist + if inherited_monster.is_equip: + inherit_bonus = MonsterStatModifierInput( + num_hp_awakening=inherited_monster.awakening_count(AwokenSkills.ENHANCEDHP.value), + num_atk_awakening=inherited_monster.awakening_count(AwokenSkills.ENHANCEDATK.value), + num_rcv_awakening=inherited_monster.awakening_count(AwokenSkills.ENHANCEDRCV.value) + ) + s_val += inherit_bonus.get_awakening_addition(key) + + if multiplayer: + num_multiboost = monster_model.awakening_count(AwokenSkills.MULTIBOOST.value) + num_multiboost += inherited_monster.awakening_count( + AwokenSkills.MULTIBOOST.value) if inherited_monster and inherited_monster.is_equip else 0 + s_val = round(round(s_val) * (1.5 ** num_multiboost)) + + return s_val + + def base_stat(self, key, lv, monster_model): + s_min = float(monster_model.stat_values[key]['min']) + s_max = float(monster_model.stat_values[key]['max']) + if monster_model.level > 1: + scale = monster_model.stat_values[key]['scale'] + s_val = s_min + (s_max - s_min) * ((min(lv, monster_model.level) - 1) / + (monster_model.level - 1)) ** scale + else: + s_val = s_min + val_at_99 = s_val + if 99 < lv: + s_val *= 1 + (monster_model.limit_mult / 11 * (min(lv, 110) - 99)) / 100 + if 110 < lv: + slb_bonus = self.LV_120_MULT_DICT[key] * val_at_99 * (lv - 110) / 1000 + s_val += slb_bonus + return s_val + + def stats(self, monster_model, lv=99, plus=0, inherit=False, stat_latents: MonsterStatModifierInput = None, + multiplayer: bool = False): + is_plus_297 = False + if plus == 297: + plus = (99, 99, 99) + is_plus_297 = True + elif plus == 0: + plus = (0, 0, 0) + + if not stat_latents: + stat_latents = MonsterStatModifierInput( + num_hp_awakening=monster_model.awakening_count(AwokenSkills.ENHANCEDHP.value), + num_atk_awakening=monster_model.awakening_count(AwokenSkills.ENHANCEDATK.value), + num_rcv_awakening=monster_model.awakening_count(AwokenSkills.ENHANCEDRCV.value), + num_voice_awakening=monster_model.awakening_count(AwokenSkills.VOICE.value) + ) + # TODO: Fix rounding + hp = int(round(self.stat(monster_model, 'hp', lv, + plus=plus[0], inherit=inherit, is_plus_297=is_plus_297, stat_latents=stat_latents, + multiplayer=multiplayer))) + atk = int(round(self.stat(monster_model, 'atk', lv, + plus=plus[1], inherit=inherit, is_plus_297=is_plus_297, stat_latents=stat_latents, + multiplayer=multiplayer))) + rcv = int(round(self.stat(monster_model, 'rcv', lv, + plus=plus[2], inherit=inherit, is_plus_297=is_plus_297, stat_latents=stat_latents, + multiplayer=multiplayer))) + weighted = int(round(hp / 10 + atk / 5 + rcv / 3)) + return hp, atk, rcv, weighted + + +monster_stats = MonsterStats() diff --git a/dbcog/models/scheduled_event_model.py b/dbcog/models/scheduled_event_model.py new file mode 100644 index 00000000..9a5ac5d7 --- /dev/null +++ b/dbcog/models/scheduled_event_model.py @@ -0,0 +1,40 @@ +from .base_model import BaseModel +from .dungeon_model import DungeonModel +from datetime import datetime +import pytz + + +class ScheduledEventModel(BaseModel): + def __init__(self, **kwargs): + self.event_id = kwargs["event_id"] + self.server_id = kwargs.get("server_id") + self.event_type_id = kwargs.get("event_type_id") + self.start_timestamp = kwargs.get("start_timestamp") + self.end_timestamp = kwargs.get("end_timestamp") + self.message = kwargs.get("message") + self.url = kwargs.get("url") + + self.dungeon: DungeonModel = kwargs.get("dungeon_model") + self.dungeon_id = kwargs.get("dungeon_id") + self.sub_dungeon_id = kwargs.get("sub_dungeon_id") + + @property + def open_datetime(self) -> datetime: + return datetime.utcfromtimestamp(self.start_timestamp).replace(tzinfo=pytz.UTC) + + @open_datetime.setter + def open_datetime(self, value: datetime): + self.start_timestamp = int(value.timestamp()) + + @property + def close_datetime(self) -> datetime: + return datetime.utcfromtimestamp(self.end_timestamp).replace(tzinfo=pytz.UTC) + + @close_datetime.setter + def close_datetime(self, value: datetime): + self.end_timestamp = int(value.timestamp()) + + def to_dict(self): + return { + 'dungeon_id': self.dungeon_id, + } diff --git a/dbcog/models/series_model.py b/dbcog/models/series_model.py new file mode 100644 index 00000000..cb05a28c --- /dev/null +++ b/dbcog/models/series_model.py @@ -0,0 +1,21 @@ +from .base_model import BaseModel + + +class SeriesModel(BaseModel): + def __init__(self, **kwargs): + self.series_id = kwargs['series_id'] + self.name_ja = kwargs['name_ja'] + self.name_en = kwargs['name_en'] + self.name_ko = kwargs['name_ko'] + self.series_type = kwargs['series_type'] + + @property + def name(self): + return self.name_en if self.name_en is not None else self.name_ja + + def to_dict(self): + return { + 'monster_id': self.series_id, + 'name_ja': self.name_ja, + 'name_en': self.name_en, + } diff --git a/dbcog/models/sub_dungeon_model.py b/dbcog/models/sub_dungeon_model.py new file mode 100644 index 00000000..d4af404d --- /dev/null +++ b/dbcog/models/sub_dungeon_model.py @@ -0,0 +1,24 @@ +from typing import List + +from .base_model import BaseModel +from .encounter_model import EncounterModel + + +class SubDungeonModel(BaseModel): + def __init__(self, encounter_models: List[EncounterModel] = None, **kwargs): + self.sub_dungeon_id = kwargs['sub_dungeon_id'] + self.dungeon_id = kwargs['dungeon_id'] + self.name_ja = kwargs['name_ja'] + self.name_en = kwargs['name_en'] + self.name_ko = kwargs['name_ko'] + self.technical = kwargs['technical'] + self.encounter_models = encounter_models + + def to_dict(self): + return { + 'sub_dungeon_id': self.sub_dungeon_id, + 'dungeon_id': self.dungeon_id, + 'name_ja': self.name_ja, + 'name_en': self.name_en, + 'name_ko': self.name_ko + } diff --git a/dbcog/monster_graph.py b/dbcog/monster_graph.py new file mode 100644 index 00000000..fb5c7625 --- /dev/null +++ b/dbcog/monster_graph.py @@ -0,0 +1,916 @@ +import json +import logging +import re +from collections import defaultdict +from datetime import datetime +from typing import Any, Dict, List, Optional, Set, Tuple, TypeVar, cast + +from networkx import MultiDiGraph +from tsutils.enums import Server + +from .database_manager import DBCogDatabase +from .models.active_skill_model import ActiveSkillModel +from .models.awakening_model import AwakeningModel +from .models.awoken_skill_model import AwokenSkillModel +from .models.base_model import BaseModel +from .models.enum_types import DEFAULT_SERVER, InternalEvoType, SERVERS +from .models.evolution_model import EvolutionModel +from .models.exchange_model import ExchangeModel +from .models.leader_skill_model import LeaderSkillModel +from .models.monster.monster_difference import MonsterDifference +from .models.monster_model import MonsterModel +from .models.series_model import SeriesModel + +logger = logging.getLogger('red.padbot-cogs.dbcog') + +M = TypeVar('M', bound=BaseModel) + +MONSTER_QUERY = """SELECT + monsters{0}.*, + monster_name_overrides.name_en AS name_override, + monster_name_overrides.is_translation, + leader_skills{0}.name_ja AS ls_name_ja, + leader_skills{0}.name_en AS ls_name_en, + leader_skills{0}.name_ko AS ls_name_ko, + leader_skills{0}.desc_ja AS ls_desc_ja, + leader_skills{0}.desc_en AS ls_desc_en, + leader_skills{0}.desc_ko AS ls_desc_ko, + leader_skills{0}.max_hp, + leader_skills{0}.max_atk, + leader_skills{0}.max_rcv, + leader_skills{0}.max_shield, + leader_skills{0}.max_combos, + leader_skills{0}.bonus_damage, + leader_skills{0}.mult_bonus_damage, + leader_skills{0}.extra_time, + leader_skills{0}.tags, + COALESCE(series.series_id, 0) AS s_series_id, + exchanges.target_monster_id AS evo_gem_id, + drops.drop_id +FROM + monsters{0} + LEFT OUTER JOIN leader_skills{0} ON monsters{0}.leader_skill_id = leader_skills{0}.leader_skill_id + LEFT OUTER JOIN active_skills{0} ON monsters{0}.active_skill_id = active_skills{0}.active_skill_id + LEFT OUTER JOIN monster_series ON monsters{0}.monster_id = monster_series.monster_id AND priority = 1 + LEFT OUTER JOIN series ON monster_series.series_id = series.series_id + LEFT OUTER JOIN monsters{0} AS target_monsters ON monsters{0}.name_ja || 'の希石' = target_monsters.name_ja + LEFT OUTER JOIN exchanges ON target_monsters.monster_id = exchanges.target_monster_id + LEFT OUTER JOIN drops ON monsters{0}.monster_id = drops.monster_id + LEFT OUTER JOIN monster_name_overrides ON monsters{0}.monster_id = monster_name_overrides.monster_id +GROUP BY + monsters{0}.monster_id""" + +ACTIVE_QUERY = """SELECT + act.active_skill_id, + act.compound_skill_type_id, + act.name_ja AS act_name_ja, + act.name_en AS act_name_en, + act.name_ko AS act_name_ko, + act.desc_ja AS act_desc_ja, + act.desc_en AS act_desc_en, + act.desc_ko AS act_desc_ko, + act.desc_templated_ja AS act_desc_templated_ja, + act.desc_templated_en AS act_desc_templated_en, + act.desc_templated_ko AS act_desc_templated_ko, + act.desc_official_ja AS act_desc_official_ja, + act.desc_official_ko AS act_desc_official_ko, + act.desc_official_en AS act_desc_official_en, + act.cooldown_turns_max, + act.cooldown_turns_min, + ass.active_subskill_id, + ass.name_ja AS ass_name_ja, + ass.name_en AS ass_name_en, + ass.name_ko AS ass_name_ko, + ass.desc_ja AS ass_desc_ja, + ass.desc_en AS ass_desc_en, + ass.desc_ko AS ass_desc_ko, + ass.desc_templated_ja AS ass_desc_templated_ja, + ass.desc_templated_en AS ass_desc_templated_en, + ass.desc_templated_ko AS ass_desc_templated_ko, + ass.board_65 AS ass_board_65, + ass.board_76 AS ass_board_76, + ass.cooldown AS ass_cooldown, + ap.active_part_id, + ap.active_skill_type_id, + ap.desc_ja AS ap_desc_ja, + ap.desc_en AS ap_desc_en, + ap.desc_ko AS ap_desc_ko, + ap.desc_templated_ja AS ap_desc_templated_ja, + ap.desc_templated_en AS ap_desc_templated_en, + ap.desc_templated_ko AS ap_desc_templated_ko, + act_ass.order_idx AS subskill_idx +FROM + active_skills{0} AS act + JOIN active_skills_subskills{0} AS act_ass ON act.active_skill_id = act_ass.active_skill_id + JOIN active_subskills{0} AS ass ON act_ass.active_subskill_id = ass.active_subskill_id + JOIN active_subskills_parts{0} AS ass_ap ON ass.active_subskill_id = ass_ap.active_subskill_id + JOIN active_parts{0} AS ap ON ass_ap.active_part_id = ap.active_part_id +ORDER BY + ass_ap.order_idx, + act_ass.order_idx +""" + +# make sure we're only looking at the most recent row for any evolution +# since the database might have old data in it still +# group by `to_id` and not `evolution_id` because PAD monsters can only have 1 recipe, and +# the evolution_id changes when data changes so grouping by evolution_id is unhelpful +EVOS_QUERY = """SELECT + evolutions{0}.* +FROM + ( + SELECT + evolution_id, + MAX(tstamp) AS tstamp + FROM + evolutions{0} + GROUP BY + to_id + ) AS latest_evolutions + INNER JOIN evolutions{0} ON evolutions{0}.evolution_id = latest_evolutions.evolution_id + AND evolutions{0}.tstamp = latest_evolutions.tstamp""" + +TRANSFORMS_QUERY = """SELECT + transformations{0}.* +FROM + transformations{0}""" + +AWAKENINGS_QUERY = """SELECT + awakenings{0}.awakening_id, + awakenings{0}.monster_id, + awakenings{0}.is_super, + awakenings{0}.order_idx, + awoken_skills.* +FROM + awakenings{0} + JOIN awoken_skills ON awakenings{0}.awoken_skill_id = awoken_skills.awoken_skill_id""" + +EGG_QUERY = """SELECT + d_egg_machine_types.name AS type, + egg_machines.* +FROM + egg_machines + JOIN d_egg_machine_types ON d_egg_machine_types.egg_machine_type_id = egg_machines.egg_machine_type_id +WHERE + start_timestamp < strftime('%s', 'now') +""" + +EXCHANGE_QUERY = """SELECT + * +FROM + exchanges +WHERE + start_timestamp < strftime('%s', 'now') +""" + +SIMPLE_QUERY = """SELECT + * +FROM + {0} +""" + +SERVER_ID_WHERE_CONDITION = " AND server_id = {}" + + +class MonsterGraph: + def __init__(self, database: DBCogDatabase, debug_monster_ids: Optional[List[int]] = None): + self.issues = [] + self.debug_monster_ids = debug_monster_ids + + self.database = database + self.max_monster_id = -1 + self.graph_dict: Dict[Server, MultiDiGraph] = { + Server.COMBINED: self.build_graph(Server.COMBINED), + Server.NA: self.build_graph(Server.NA), + } + + self._cache_graphs() + + def build_graph(self, server: Server) -> MultiDiGraph: + graph = MultiDiGraph() + + table_suffix = "" + where = "" + if server != Server.COMBINED: + table_suffix = "_" + server.value.lower() + where = SERVER_ID_WHERE_CONDITION.format(["JP", "NA", "KR"].index(server.value)) + + ms = self.database.query_many(MONSTER_QUERY.format(table_suffix)) + es = self.database.query_many(EVOS_QUERY.format(table_suffix)) + tfs = self.database.query_many(TRANSFORMS_QUERY.format(table_suffix)) + aws = self.database.query_many(AWAKENINGS_QUERY.format(table_suffix)) + ems = self.database.query_many(EGG_QUERY.format(table_suffix) + where) + exs = self.database.query_many(EXCHANGE_QUERY.format(table_suffix) + where) + + as_query = self.database.query_many(ACTIVE_QUERY.format(table_suffix)) + acts = {} + for row in as_query: + if row.active_skill_id not in acts: + acts[row.active_skill_id] = { + 'active_skill_id': row.active_skill_id, + 'compound_skill_type_id': row.compound_skill_type_id, + 'name_ja': row.act_name_ja, + 'name_en': row.act_name_en, + 'name_ko': row.act_name_ko, + 'desc_ja': row.act_desc_ja, + 'desc_en': row.act_desc_en, + 'desc_ko': row.act_desc_ko, + 'desc_templated_ja': row.act_desc_templated_ja, + 'desc_templated_en': row.act_desc_templated_en, + 'desc_templated_ko': row.act_desc_templated_ko, + 'desc_official_ja': row.act_desc_official_ja, + 'desc_official_ko': row.act_desc_official_ko, + 'desc_official_en': row.act_desc_official_en, + 'cooldown_turns_max': row.cooldown_turns_max, + 'cooldown_turns_min': row.cooldown_turns_min, + + 'active_subskills': [] + } + skill = acts[row.active_skill_id] + if len(skill['active_subskills']) <= row.subskill_idx: + skill['active_subskills'].append({ + 'active_subskill_id': row.active_subskill_id, + 'name_ja': row.ass_name_ja, + 'name_en': row.ass_name_en, + 'name_ko': row.ass_name_ko, + 'desc_ja': row.ass_desc_ja, + 'desc_en': row.ass_desc_en, + 'desc_ko': row.ass_desc_ko, + 'desc_templated_ja': row.ass_desc_templated_ja, + 'desc_templated_en': row.ass_desc_templated_en, + 'desc_templated_ko': row.ass_desc_templated_ko, + 'board_65': row.ass_board_65, + 'board_76': row.ass_board_76, + 'cooldown': row.ass_cooldown, + + 'active_parts': [] + }) + subskill = skill['active_subskills'][row.subskill_idx] + subskill['active_parts'].append({ + 'active_part_id': row.active_part_id, + 'active_skill_type_id': row.active_skill_type_id, + 'desc_ja': row.ap_desc_ja, + 'desc_en': row.ap_desc_en, + 'desc_ko': row.ap_desc_ko, + 'desc_templated_ja': row.ap_desc_templated_ja, + 'desc_templated_en': row.ap_desc_templated_en, + 'desc_templated_ko': row.ap_desc_templated_ko, + }) + acts = {asid: ActiveSkillModel(**act) for asid, act in acts.items()} + + mtoawo = defaultdict(list) + for a in aws: + awoken_skill_model = AwokenSkillModel(**a) + awakening_model = AwakeningModel(awoken_skill_model=awoken_skill_model, **a) + mtoawo[a.monster_id].append(awakening_model) + + mtoegg = defaultdict(lambda: {'pem': False, 'rem': False, 'adpem': False}) + for e in ems: + data = json.loads(e.contents) + e_type = 'pem' if e.type == "PEM" else 'adpem' if e.type == "VEM" else 'rem' + for m in data: + idx = int(m[1:-1]) # Remove parentheses + mtoegg[idx][e_type] = True + + ss_query = self.database.query_many(SIMPLE_QUERY.format('series')) + series = {} + for row in ss_query: + series[row.series_id] = SeriesModel(series_id=row.series_id, + name_ja=row.name_ja, + name_en=row.name_en, + name_ko=row.name_ko, + series_type=row.series_type + ) + + mss_query = self.database.query_many(SIMPLE_QUERY.format('monster_series')) + monster_series = defaultdict(set) + for row in mss_query: + monster_series[row.monster_id].add(series[row.series_id]) + + for m in ms: + if self.debug_monster_ids is not None and m.monster_id not in self.debug_monster_ids: + continue + ls_model = LeaderSkillModel(leader_skill_id=m.leader_skill_id, + name_ja=m.ls_name_ja, + name_en=m.ls_name_en, + name_ko=m.ls_name_ko, + desc_ja=m.ls_desc_ja, + desc_en=m.ls_desc_en, + desc_ko=m.ls_desc_ko, + max_hp=m.max_hp, + max_atk=m.max_atk, + max_rcv=m.max_rcv, + max_shield=m.max_shield, + max_combos=m.max_combos, + bonus_damage=m.bonus_damage, + mult_bonus_damage=m.mult_bonus_damage, + extra_time=m.extra_time, + tags=m.tags, + ) if m.leader_skill_id != 0 else None + + m_model = MonsterModel(monster_id=m.monster_id, + base_evo_id=m.base_id, + monster_no_jp=m.monster_no_jp, + monster_no_na=m.monster_no_na, + monster_no_kr=m.monster_no_kr, + name_is_translation=m.is_translation, + awakenings=mtoawo[m.monster_id], + leader_skill=ls_model, + active_skill=acts.get(m.active_skill_id), + series=series[m.s_series_id], + all_series=monster_series[m.monster_id] or {series[0]}, + series_id=m.s_series_id, + group_id=m.group_id, + collab_id=m.collab_id, + attribute_1_id=m.attribute_1_id, + attribute_2_id=m.attribute_2_id, + name_ja=m.name_ja, + name_en=m.name_en, + name_ko=m.name_ko, + name_en_override=m.name_override, + rarity=m.rarity, + is_farmable=m.drop_id is not None, + in_pem=mtoegg[m.monster_id]['pem'], + in_rem=mtoegg[m.monster_id]['rem'], + in_vem=mtoegg[m.monster_id]['adpem'], + buy_mp=m.buy_mp, + sell_mp=m.sell_mp, + sell_gold=m.sell_gold, + reg_date=m.reg_date, + on_jp=m.on_jp == 1, + on_na=m.on_na == 1, + on_kr=m.on_kr == 1, + type_1_id=m.type_1_id, + type_2_id=m.type_2_id, + type_3_id=m.type_3_id, + is_inheritable=m.inheritable == 1, + is_stackable=m.stackable == 1, + evo_gem_id=m.evo_gem_id, + orb_skin_id=m.orb_skin_id, + bgm_id=m.bgm_id, + cost=m.cost, + level=m.level, + exp=m.exp, + fodder_exp=m.fodder_exp, + limit_mult=m.limit_mult, + voice_id_jp=m.voice_id_jp, + voice_id_na=m.voice_id_na, + hp_max=m.hp_max, + hp_min=m.hp_min, + hp_scale=m.hp_scale, + atk_max=m.atk_max, + atk_min=m.atk_min, + atk_scale=m.atk_scale, + rcv_max=m.rcv_max, + rcv_min=m.rcv_min, + rcv_scale=m.rcv_scale, + latent_slots=m.latent_slots, + has_animation=m.has_animation == 1, + has_hqimage=m.has_hqimage == 1, + server_priority=server, + ) + if not m_model: + continue + + graph.add_node(m.monster_id, model=m_model) + self.max_monster_id = max(self.max_monster_id, m.monster_id) + + if m.evo_gem_id: + if self.debug_monster_ids is None or m.evo_gem_id in self.debug_monster_ids: + graph.add_edge(m.monster_id, m.evo_gem_id, type='evo_gem_from') + graph.add_edge(m.evo_gem_id, m.monster_id, type='evo_gem_of') + + for e in es: + if self.debug_monster_ids is not None: + if e.from_id not in self.debug_monster_ids or e.to_id not in self.debug_monster_ids: + continue + + evo_model = EvolutionModel( + evolution_type=e.evolution_type, + reversible=e.reversible, + from_id=e.from_id, + to_id=e.to_id, + mat_1_id=self.debug_validate_id(e.mat_1_id), + mat_2_id=self.debug_validate_id(e.mat_2_id), + mat_3_id=self.debug_validate_id(e.mat_3_id), + mat_4_id=self.debug_validate_id(e.mat_4_id), + mat_5_id=self.debug_validate_id(e.mat_5_id), + tstamp=e.tstamp, + ) + + graph.add_edge(evo_model.from_id, evo_model.to_id, type='evolution', model=evo_model) + graph.add_edge(evo_model.to_id, evo_model.from_id, type='back_evolution', model=evo_model) + + # for material_of queries + already_used_in_this_evo = set() # don't add same mat more than once per evo + for mat in evo_model.mats: + if mat in already_used_in_this_evo: + continue + graph.add_edge( + mat, evo_model.to_id, type="material_of", model=evo_model) + already_used_in_this_evo.add(mat) + + for tf in tfs: + if self.debug_monster_ids is not None: + if tf.from_monster_id not in self.debug_monster_ids or tf.to_monster_id not in self.debug_monster_ids: + continue + + # Make a model with percentages here. + + graph.add_edge(tf.from_monster_id, tf.to_monster_id, type='transformation') + graph.add_edge(tf.to_monster_id, tf.from_monster_id, type='back_transformation') + + exchanges = defaultdict(set) + for ex in exs: + model = ExchangeModel(**ex) + for vendor_id in re.findall(r'\d+', ex.required_monster_ids): + if self.debug_monster_ids is not None: + if ex.target_monster_id not in self.debug_monster_ids \ + or int(vendor_id) not in self.debug_monster_ids: + continue + exchanges[(int(vendor_id), ex.target_monster_id)].add(model) + for (sell_id, buy_id), models in exchanges.items(): + graph.add_edge(buy_id, sell_id, type='exchange_from', models=models) + graph.add_edge(sell_id, buy_id, type='exchange_for', models=models) + + return graph + + def _cache_graphs(self) -> None: + for server in self.graph_dict: + for mid in self.graph_dict[server].nodes: + if 'model' in self.graph_dict[server].nodes[mid]: + alt_ids = self.process_alt_ids(self.get_monster(mid, server=server)) + self.graph_dict[server].nodes[mid]['alt_versions'] = alt_ids + if self.debug_monster_ids is not None: + self.graph_dict[server].nodes[mid]['model'].base_evo_id = alt_ids[0] + else: + alert = False + for edges in self.graph_dict[server][mid].values(): + for edge in edges.values(): + if not edge['type'].startswith('exchange'): + alert |= True + if alert: + self.issues.append(f"{mid} has no model in the {server.name} graph.") + + def _get_edges(self, monster: MonsterModel, etype) -> Set[int]: + return {mid for mid, atlas in self.graph_dict[monster.server_priority][monster.monster_id].items() + for edge in atlas.values() if edge.get('type') == etype} + + def _get_edge_or_none(self, monster: MonsterModel, etype: str) -> Optional[int]: + edges = self._get_edges(monster, etype) + if edges: + return edges.pop() + + def _get_edge_model_set(self, monster: MonsterModel, etype: str) -> Set[BaseModel]: + possible_results = set() + for atlas in self.graph_dict[monster.server_priority][monster.monster_id].values(): + for edge in atlas.values(): + if edge.get('type') == etype: + possible_results.update(edge['models']) + return possible_results + + def _get_newest_edge_model(self, monster: MonsterModel, etype: str) -> Optional[BaseModel]: + possible_results = set() + for atlas in self.graph_dict[monster.server_priority][monster.monster_id].values(): + for edge in atlas.values(): + if edge.get('type') == etype: + possible_results.add(edge['model']) + if possible_results: + return min(possible_results, key=lambda x: x.tstamp) + + def get_monster(self, monster_id: int, *, server: Server = DEFAULT_SERVER, do_logging: bool = False) \ + -> Optional[MonsterModel]: + if monster_id not in self.graph_dict[server].nodes: + return None + if 'model' not in self.graph_dict[server].nodes[monster_id]: + return None + return self.graph_dict[server].nodes[monster_id]['model'] + + def get_all_monsters(self, server: Server) -> Set[MonsterModel]: + # Fail gracefully if one of the nodes doesn't exist + # TODO: log which node doesn't exist? Or unneeded bc we will do that at startup + return {mdata['model'] for mdata in self.graph_dict[server].nodes.values() if mdata.get('model')} + + def get_evo_tree(self, monster: MonsterModel) -> List[MonsterModel]: + while (prev := self.get_prev_evolution(monster)): + monster = prev + return self.get_evo_tree_from_base(monster) + + def get_evo_tree_from_base(self, base_monster: MonsterModel) -> List[MonsterModel]: + mons = [base_monster] + for evo in sorted(self.get_next_evolutions(base_monster), key=lambda m: m.monster_id): + mons += self.get_evo_tree_from_base(evo) + return mons + + def get_transform_monsters(self, monster: MonsterModel) -> Set[MonsterModel]: + mons = set() + to_check = {monster} + while to_check: + mon = to_check.pop() + if mon in mons: + continue + to_check.update(self.get_next_transforms(mon)) + to_check.update(self.get_prev_transforms(mon)) + mons.add(mon) + return mons + + def process_alt_ids(self, monster: MonsterModel) -> List[int]: + return [m.monster_id for m in self.process_alt_monsters_from_base(self.get_base_monster(monster))] + + def process_alt_monsters_from_base(self, base_monster: MonsterModel) -> List[MonsterModel]: + ids = [base_monster] + for transform in sorted(self.get_next_transforms(base_monster), key=self.get_id_order_key): + if transform and (transform.monster_id > base_monster.monster_id + or transform.monster_id == 5802): # I hate DMG very much + ids += [mid for mid in self.process_alt_monsters_from_base(transform) if mid not in ids] + for evo in sorted(self.get_next_evolutions(base_monster), key=self.get_id_order_key): + ids += self.process_alt_monsters_from_base(evo) + return ids + + def get_id_order_key(self, monster: MonsterModel) -> Tuple[Any, ...]: + evo_ordering = { + InternalEvoType.Reincarnated: -1, + InternalEvoType.SuperReincarnated: -1, + InternalEvoType.Base: 0, + InternalEvoType.Normal: 0, + InternalEvoType.Ultimate: 1, + InternalEvoType.Pixel: 2, + InternalEvoType.Assist: 3, + } + return ( + evo_ordering[self.true_evo_type(monster)], + '覚醒' in monster.name_ja or 'awoken' in monster.name_en.lower(), + monster.monster_id, + ) + + def get_alt_ids(self, monster: MonsterModel) -> List[int]: + return self.graph_dict[monster.server_priority].nodes[monster.monster_id]['alt_versions'] + + def get_alt_monsters(self, monster: MonsterModel) -> List[MonsterModel]: + return [self.get_monster(m_id, server=monster.server_priority) for m_id in self.get_alt_ids(monster)] + + def get_monsters_with_same_id(self, monster: MonsterModel) -> Set[MonsterModel]: + return {*filter(None, [self.get_monster(monster.monster_id % 10_000, server=monster.server_priority), + self.get_monster(monster.monster_id + 10_000, server=monster.server_priority)])} + + def get_base_id(self, monster) -> int: + # This fixes DMG. I *hate* DMG. + if monster.base_evo_id == 5802: + return 5810 + + while True: + prev = self.get_prev_transform(monster) + if prev is None: + break + if prev.monster_id >= monster.monster_id: + break + monster = prev + + if self.debug_monster_ids is not None: + while (prev := self.get_prev_evolution(monster)): + monster = prev + return monster.monster_id + else: + return monster.base_evo_id + + def get_base_monster(self, monster: MonsterModel) -> MonsterModel: + return self.get_monster(self.get_base_id(monster), server=monster.server_priority) + + def monster_is_base(self, monster: MonsterModel) -> bool: + return self.get_base_id(monster) == monster.monster_id + + def get_transform_base(self, monster: MonsterModel) -> MonsterModel: + # NOTE: This assumes that no two monsters will transform to the same monster. This + # also doesn't work for monsters like DMG which are transforms but also base + # cards. This also assumes that the "base" monster will be the lowest id in + # the case of a cyclical transform. + seen = set() + curr = monster + while curr not in seen: + seen.add(curr) + prev_mon = self.get_prev_transform(curr) + if prev_mon is not None: + curr = prev_mon + else: + break + else: + curr = min(seen, key=lambda m: m.monster_id) + return curr + + def monster_is_transform_base(self, monster: MonsterModel) -> bool: + return self.get_transform_base(monster) == monster + + def get_numerical_sort_top_monster(self, monster: MonsterModel) -> Optional[MonsterModel]: + alt_cards = self.get_alt_monsters(monster) + if alt_cards is None: + return None + return sorted(alt_cards, key=lambda m: m.monster_id)[-1] + + def get_evolution(self, monster) -> Optional[EvolutionModel]: + return self._get_newest_edge_model(monster, 'back_evolution') + + def monster_is_reversible_evo(self, monster: MonsterModel) -> bool: + prev_evo = self.get_evolution(monster) + return prev_evo is not None and prev_evo.reversible + + def monster_is_reincarnated(self, monster: MonsterModel) -> bool: + if self.monster_is_reversible_evo(monster): + return False + while (monster := self.get_prev_evolution(monster)): + if self.monster_is_reversible_evo(monster): + return True + return False + + def monster_is_normal_evo(self, monster: MonsterModel) -> bool: + return not (self.monster_is_reversible_evo(monster) + or self.monster_is_reincarnated(monster) + or self.monster_is_base(monster)) + + def monster_is_first_evo(self, monster: MonsterModel) -> bool: + prev = self.get_prev_evolution(monster) + if prev: + return self.get_prev_evolution(prev) is None + return False + + def monster_is_second_ultimate(self, monster: MonsterModel) -> bool: + if self.monster_is_reversible_evo(monster): + prev = self.get_prev_evolution(monster) + if prev is not None: + return self.monster_is_reversible_evo(prev) + return False + + def true_evo_type(self, monster: MonsterModel) -> InternalEvoType: + if monster.is_equip: + return InternalEvoType.Assist + if self.monster_is_base(monster): + return InternalEvoType.Base + + evo = self.get_evolution(monster) + if evo is None: + return InternalEvoType.Base + if evo.is_super_reincarnated: + return InternalEvoType.SuperReincarnated + elif evo.is_pixel: + return InternalEvoType.Pixel + + if self.monster_is_reincarnated(monster): + return InternalEvoType.Reincarnated + elif self.monster_is_reversible_evo(monster): + return InternalEvoType.Ultimate + return InternalEvoType.Normal + + def get_prev_evolution(self, monster: MonsterModel) -> Optional[MonsterModel]: + pe = self._get_edge_or_none(monster, 'back_evolution') + return pe and self.get_monster(pe, server=monster.server_priority) + + def get_next_evolutions(self, monster: MonsterModel) -> Set[MonsterModel]: + return {self.get_monster(mid, server=monster.server_priority) + for mid in self._get_edges(monster, 'evolution')} + + def get_prev_transforms(self, monster: MonsterModel) -> Set[MonsterModel]: + return {self.get_monster(mid, server=monster.server_priority) + for mid in self._get_edges(monster, 'back_transformation')} + + def get_prev_transform(self, monster: MonsterModel) -> Optional[MonsterModel]: + # This doesn't need a special function, so I'll remove it later + transforms = self.get_prev_transforms(monster) + if not transforms: + return None + return min(transforms, key=lambda m: m.monster_id) + + def get_next_transforms(self, monster: MonsterModel) -> Set[MonsterModel]: + return {self.get_monster(mid, server=monster.server_priority) + for mid in self._get_edges(monster, 'transformation')} + + def get_next_transform(self, monster: MonsterModel) -> Optional[MonsterModel]: + # Figure out how to get rid of this + transforms = self.get_next_transforms(monster) + if not transforms: + return None + return max(transforms, key=lambda m: m.monster_id) + + def get_all_prev_evolutions(self, monster: MonsterModel, *, include_self: bool = True) -> List[MonsterModel]: + ret = [] + if include_self: + ret.append(monster) + cur_mon = monster + while True: + pe = self.get_prev_evolution(cur_mon) + if pe is None: + break + ret.append(pe) + cur_mon = pe + return ret + + def get_all_prev_transforms(self, monster: MonsterModel, *, include_self: bool = True, + cyclical_wrap: bool = False) -> List[MonsterModel]: + ret = [] + if include_self: + ret.append(monster) + cur_mon = monster + is_cycle = False + while True: + pt = self.get_prev_transform(cur_mon) + if pt is None: + break + if pt.monster_id == monster.monster_id: + is_cycle = True + break + ret.append(pt) + cur_mon = pt + if is_cycle and not cyclical_wrap: + # remove everything that comes "after" monster in the cycle (i.e. has a higher monster id) + to_pop = [] + for i, mon in enumerate(ret): + if mon.monster_id > monster.monster_id: + to_pop.append(i) + to_pop.reverse() + for i in to_pop: + ret.pop(i) + return ret + + def get_all_next_evolutions(self, monster: MonsterModel, include_self: bool = True) -> Set[MonsterModel]: + to_parse = {monster} + ret = set() + while to_parse: + mon = to_parse.pop() + if mon in ret: + continue + ret.add(mon) + to_parse.update(self.get_next_evolutions(mon)) + if not include_self: + ret.remove(monster) + return ret + + def get_monster_depth(self, monster: MonsterModel) -> float: + if monster.monster_id == 5802: + # DMG sucks! + return 0 + + depth = 0.0 + while None is not (prev := self.get_prev_transform(monster)) \ + and prev.monster_id < monster.monster_id: + monster = prev + depth += .01 + while None is not (monster := self.get_prev_evolution(monster)): + depth += 1 + return depth + + def get_adjusted_rarity(self, monster: MonsterModel) -> float: + # After the revo/srevo changes, rarity doesn't work, and Mega GFEs means that depth doesn't work + # The max of base rarity + the depth or the current rarity. This fixes revo rarities being nerfed + return max(self.get_base_monster(monster).rarity + self.get_monster_depth(monster), monster.rarity) + + def evo_mats(self, monster: MonsterModel) -> List[MonsterModel]: + evo = self.get_evolution(monster) + if evo is None: + return [] + return [self.get_monster(mat, server=monster.server_priority) for mat in evo.mats] + + def monster_is_farmable_evo(self, monster: MonsterModel) -> bool: + return any(alt.is_farmable for alt in self.get_alt_monsters(monster)) + + def monster_is_mp_evo(self, monster: MonsterModel) -> bool: + return any(alt.in_mpshop for alt in self.get_alt_monsters(monster)) + + def monster_is_pem_evo(self, monster: MonsterModel) -> bool: + return any(alt.in_pem for alt in self.get_alt_monsters(monster)) + + def monster_is_rem_evo(self, monster: MonsterModel) -> bool: + return any(alt.in_rem for alt in self.get_alt_monsters(monster)) + + def monster_is_vem_evo(self, monster: MonsterModel) -> bool: + return any(alt.in_vem for alt in self.get_alt_monsters(monster)) + + def monster_is_orb_skin_evo(self, monster: MonsterModel) -> bool: + return any(alt.orb_skin_id for alt in self.get_alt_monsters(monster)) + + def monster_is_bgm_evo(self, monster: MonsterModel) -> bool: + return any(alt.bgm_id for alt in self.get_alt_monsters(monster)) + + def monster_is_exchange(self, monster: MonsterModel) -> bool: + return bool(self._get_edges(monster, 'exchange_from')) + + def monster_is_exchange_evo(self, monster: MonsterModel) -> bool: + return any(self.monster_is_exchange(alt) for alt in self.get_alt_monsters(monster)) + + def get_monster_exchange_mat_ids(self, monster: MonsterModel) -> Set[int]: + return self._get_edges(monster, 'exchange_from') + + def get_monster_exchange_models(self, monster: MonsterModel) -> Set[ExchangeModel]: + return cast(Set[ExchangeModel], self._get_edge_model_set(monster, 'exchange_from')) + + def get_monster_exchange_mats(self, monster: MonsterModel) -> Set[MonsterModel]: + return {self.get_monster(mid, server=monster.server_priority) + for mid in self.get_monster_exchange_mat_ids(monster)} + + def monster_is_vendor_exchange(self, monster: MonsterModel) -> bool: + ids = self.get_monster_exchange_mats(monster) + return bool(ids) and all(15 in [t.value for t in m.types] for m in ids) + + def monster_is_black_medal_exchange(self, monster: MonsterModel) -> bool: + return 5155 in [m.monster_id for m in self.get_monster_exchange_mats(monster)] + + def monster_is_black_medal_exchange_evo(self, monster: MonsterModel) -> bool: + return any(self.monster_is_black_medal_exchange(alt) for alt in self.get_alt_monsters(monster)) + + def monster_is_currently_exchangable(self, monster: MonsterModel, server: Optional[Server] = None) -> bool: + models = self.get_monster_exchange_models(monster) + now = datetime.now() + for model in models: + if model.start_timestamp < now < model.end_timestamp \ + and (server is None or server == model.server): + return True + return False + + def monster_is_currently_exchangable_evo(self, monster: MonsterModel, server: Optional[Server] = None) -> bool: + return any(self.monster_is_currently_exchangable(alt, server) for alt in self.get_alt_monsters(monster)) + + def monster_is_permanent_exchange(self, monster: MonsterModel) -> bool: + return any(model.permanent or model.end_timestamp.year > 2030 + for model in self.get_monster_exchange_models(monster)) + + def monster_is_permanent_exchange_evo(self, monster: MonsterModel) -> bool: + return any(self.monster_is_permanent_exchange(alt) for alt in self.get_alt_monsters(monster)) + + def monster_is_temporary_exchange(self, monster: MonsterModel) -> bool: + """Not necessarily a current exchange""" + return not all(model.permanent and model.end_timestamp.year > 2030 + for model in self.get_monster_exchange_models(monster)) + + def monster_is_temporary_exchange_evo(self, monster: MonsterModel) -> bool: + """Not necessarily a current exchange""" + return any(self.monster_is_temporary_exchange(alt) for alt in self.get_alt_monsters(monster)) + + def monster_is_new(self, monster: MonsterModel) -> bool: + latest_time = max(am.reg_date for am in self.get_alt_monsters(monster)) + return monster.reg_date == latest_time + + def monster_acquisition(self, monster: MonsterModel) -> str: + if self.monster_is_rem_evo(monster): + return 'REM Card' + elif self.monster_is_mp_evo(monster): + return 'MP Shop Card' + elif self.monster_is_farmable_evo(monster): + return 'Farmable Card' + elif self.monster_is_pem_evo(monster): + return 'PEM Card' + elif self.monster_is_vem_evo(monster): + return 'AdPEM Card' + + def numeric_next_monster(self, monster: MonsterModel) -> Optional[MonsterModel]: + next_monster = None + offset = 1 + while next_monster is None and monster.monster_id + offset <= self.max_monster_id: + next_monster = self.get_monster(monster.monster_id + offset, server=monster.server_priority) + offset += 1 + if next_monster is None: + return None + return next_monster + + def numeric_prev_monster(self, monster: MonsterModel) -> Optional[MonsterModel]: + prev_monster = None + offset = 1 + while prev_monster is None and monster.monster_id - offset >= 1: + prev_monster = self.get_monster(monster.monster_id - offset, server=monster.server_priority) + offset += 1 + if prev_monster is None: + return None + return prev_monster + + def evo_gem_monster(self, monster: MonsterModel) -> Optional[MonsterModel]: + if monster.evo_gem_id is None: + return None + return self.get_monster(monster.evo_gem_id, server=monster.server_priority) + + def get_monster_from_evo_gem(self, monster: MonsterModel) -> Optional[MonsterModel]: + return self.get_monster(self._get_edge_or_none(monster, "evo_gem_of"), server=monster.server_priority) + + def monster_is_evo_gem(self, monster: MonsterModel) -> bool: + # Evo gems can no longer be calculated via exchange as of Pixel Volcano Dragon + return monster.name_en.endswith("'s Gem") or monster.name_ja.endswith("の希石") + + def monster_is_gfe_exchange(self, monster: MonsterModel, stars: Optional[int] = None) -> bool: + monsters = {self.get_monster(mid, server=monster.server_priority) + for exc in self.get_monster_exchange_models(monster) + for mid in exc.required_monster_ids} + if stars is not None: + monsters = {m for m in monsters if m.rarity == stars} + return len({m for m in monsters for s in m.all_series if s.series_id == 34}) > 1 + + def material_of_ids(self, monster: MonsterModel) -> List[int]: + return sorted(self._get_edges(monster, 'material_of')) + + def material_of_monsters(self, monster: MonsterModel) -> List[MonsterModel]: + return [self.get_monster(m, server=monster.server_priority) + for m in self.material_of_ids(monster)] + + def monster_difference(self, monster: MonsterModel, server) -> MonsterDifference: + return monster.get_difference(self.get_monster(monster.monster_id, server=server)) + + def monster_is_discrepant(self, monster: MonsterModel) -> bool: + return any((md := self.monster_difference(monster, server)) and not md.existance + for server in SERVERS) + + def debug_validate_id(self, monster_id: int) -> Optional[int]: + if self.debug_monster_ids is None or monster_id in self.debug_monster_ids: + return monster_id diff --git a/dbcog/monster_index.py b/dbcog/monster_index.py new file mode 100644 index 00000000..d7b3d015 --- /dev/null +++ b/dbcog/monster_index.py @@ -0,0 +1,641 @@ +import asyncio +import csv +import io +import logging +import re +from collections import defaultdict +from typing import Dict, List, Optional + +import aiohttp +from redbot.core.utils import AsyncIter +from tsutils.enums import Server +from tsutils.formatting import contains_ja + +from .errors import InvalidGraphState +from .models.enum_types import AwokenSkills, DEFAULT_SERVER +from .models.monster_model import MonsterModel +from .monster_graph import MonsterGraph +from dbcog.find_monster.token_mappings import ALL_TOKEN_DICTS, AWOKEN_SKILL_MAP, COLOR_MAP, DUAL_COLOR_MAP, EVO_MAP, EvoTypes, \ + HAZARDOUS_IN_NAME_MODS, KNOWN_MODIFIERS, LEGAL_END_TOKENS, MISC_MAP, MULTI_WORD_TOKENS, MiscModifiers, \ + PROBLEMATIC_SERIES_TOKENS, SUB_COLOR_MAP, TYPE_MAP + +logger = logging.getLogger('red.pad-cogs.dbcog.monster_index') + +SHEETS_PATTERN = 'https://docs.google.com/spreadsheets/d/1EoZJ3w5xsXZ67kmarLE4vfrZSIIIAfj04HXeZVST3eY' \ + '/pub?gid={}&single=true&output=csv' +CARDNAME_OVERRIDE_SHEET = SHEETS_PATTERN.format(0) +TREENAME_OVERRIDES_SHEET = SHEETS_PATTERN.format(2070615818) +CARD_MODIFIER_OVERRIDE_SHEET = SHEETS_PATTERN.format(2089525837) +TREE_MODIFIER_OVERRIDE_SHEET = SHEETS_PATTERN.format(1372419168) +CONTENT_TOKEN_ALIAS_SHEET = SHEETS_PATTERN.format(1229125459) +SERIES_OVERRIDES_SHEET = SHEETS_PATTERN.format(959933643) + + +class MonsterIndex: + def __init__(self, server: Server = DEFAULT_SERVER): + self.is_ready = asyncio.Event() + self.server = server + self.issues = [] + + self.monster_id_to_cardname = defaultdict(set) + self.monster_id_to_treename = defaultdict(set) + self.treename_overrides = set() + self.monster_id_to_name = defaultdict(set) + self.monster_id_to_forcedfluff = defaultdict(set) + self.series_id_to_pantheon_nickname = defaultdict(set) + self.content_token_aliases = defaultdict(set) + self.manual_modifiers = defaultdict(set) + self.manual_removed_modifiers = defaultdict(set) + + self.all_name_tokens = {} + self.manual = {} + self.manual_cardnames = defaultdict(set) + self.manual_treenames = defaultdict(set) + self.name_tokens = defaultdict(set) + self.fluff_tokens = defaultdict(set) + + self.all_modifiers = set() + self._known_mods = set() + self.modifiers = defaultdict(set) + self.suffixes = set() + + self.multi_word_tokens = {} + self.mwtoken_creators = defaultdict(set) + self.mwt_to_len = defaultdict(lambda: 1) + + self.graph: Optional[MonsterGraph] = None + + async def reset(self, graph: MonsterGraph): + self.is_ready.clear() + self.issues = [] + + self.monster_id_to_cardname = defaultdict(set) + self.monster_id_to_treename = defaultdict(set) + self.treename_overrides = set() + self.monster_id_to_name = defaultdict(set) + self.monster_id_to_forcedfluff = defaultdict(set) + self.series_id_to_pantheon_nickname = defaultdict(set) + self.content_token_aliases = defaultdict(set) + self.manual_modifiers = defaultdict(set) + self.manual_removed_modifiers = defaultdict(set) + + self.all_name_tokens = {} + self.manual = {} + self.manual_cardnames = defaultdict(set) + self.manual_treenames = defaultdict(set) + self.name_tokens = defaultdict(set) + self.fluff_tokens = defaultdict(set) + + self.all_modifiers = set() + self._known_mods = set() + self.modifiers = defaultdict(set) + self.suffixes = set() + + self.multi_word_tokens = {} + self.mwtoken_creators = defaultdict(set) + self.mwt_to_len = defaultdict(lambda: 1) + + await self.setup(graph) + + async def setup(self, graph: MonsterGraph): + self.graph = graph + monsters = graph.get_all_monsters(self.server) + + self.series_id_to_pantheon_nickname = \ + defaultdict(set, {m.series_id: {m.series.name_en.lower().replace(" ", "")} + for m in monsters + if m.series.name_en.lower() not in PROBLEMATIC_SERIES_TOKENS}) + + self.multi_word_tokens = {tuple(m.series.name_en.lower().split()) + for m + in monsters + if " " in m.series.name_en.strip()}.union(MULTI_WORD_TOKENS) + + treenames_data, nickname_data, pantheon_data, nt_alias_data, treemod_data, mod_data = await asyncio.gather( + sheet_to_reader(TREENAME_OVERRIDES_SHEET, + ('base_id', 'new_treename', 'normal_prio', 'overrides')), + sheet_to_reader(CARDNAME_OVERRIDE_SHEET, + ('monster_id', 'name_en', 'normal_prio', 'overrides', 'fluff')), + sheet_to_reader(SERIES_OVERRIDES_SHEET, + ('series_id', 'alias')), + sheet_to_reader(CONTENT_TOKEN_ALIAS_SHEET, + ('tokens', 'alias')), + sheet_to_reader(TREE_MODIFIER_OVERRIDE_SHEET, + ('base_id', 'modifiers')), + sheet_to_reader(CARD_MODIFIER_OVERRIDE_SHEET, + ('monster_id', 'modifiers', 'remove')), + ) + + for data in treenames_data: + if data['base_id'].isdigit(): + mid = int(data['base_id']) + name = data['new_treename'].strip().lower() + try: + monster = graph.get_monster(mid, server=self.server) + except InvalidGraphState: + continue + if not monster: + continue + if data['overrides']: + for evomid in self.graph.get_alt_ids(self.graph.get_monster(mid, server=self.server)): + self.treename_overrides.add(evomid) + if data['normal_prio']: + for evomid in self.graph.get_alt_ids(self.graph.get_monster(mid, server=self.server)): + self.monster_id_to_name[evomid].update(self._name_to_tokens(name)) + else: + if " " in name: + self.mwtoken_creators[name.lower().replace(" ", "")] \ + .add(graph.get_monster(mid, server=self.server)) + self.multi_word_tokens.add(tuple(name.lower().split(" "))) + self.monster_id_to_treename[mid].add(name.lower().replace(" ", "")) + + for data in nickname_data: + if data['monster_id'].isdigit(): + mid = int(data['monster_id']) + name = data['name_en'].strip().lower() + try: + monster = graph.get_monster(mid, server=self.server) + except InvalidGraphState: + continue + if not monster: + continue + if data['fluff']: + self.monster_id_to_forcedfluff[mid].update(self._name_to_tokens(name)) + if data['normal_prio'] or data['overrides']: + self.issues.append(f"Invalid sheet settings for nickname `{name}`.") + continue + if data['normal_prio']: + self.monster_id_to_name[mid].update(self._name_to_tokens(name)) + if data['overrides']: + self.treename_overrides.add(mid) + else: + if " " in name: + self.mwtoken_creators[name.lower().replace(" ", "")] \ + .add(graph.get_monster(mid, server=self.server)) + self.multi_word_tokens.add(tuple(name.lower().split(" "))) + self.monster_id_to_cardname[mid].add(name.lower().replace(" ", "")) + + for data in pantheon_data: + if data['series_id'].isdigit(): + sid = int(data['series_id']) + name = data['alias'].strip().lower() + if " " in name: + self.multi_word_tokens.add(tuple(name.lower().split(" "))) + self.series_id_to_pantheon_nickname[sid].add(name.lower().replace(" ", "")) + + for data in nt_alias_data: + self.content_token_aliases[frozenset(re.split(r'[,\s]+', data['tokens']))].add(data['alias']) + + for data in mod_data: + if data['monster_id'].isdigit(): + mid = int(data['monster_id']) + try: + monster = graph.get_monster(mid, server=self.server) + except InvalidGraphState: + continue + if not monster: + continue + for mod in data['modifiers'].split(","): + mod = mod.strip().lower() + if " " in mod: + self.multi_word_tokens.add(tuple(mod.lower().split(" "))) + mod = mod.lower().replace(" ", "") + if data['remove']: + self.manual_removed_modifiers[mid].update(mod) + else: + self.manual_modifiers[mid].update(get_modifier_aliases(mod)) + + for data in treemod_data: + if data['base_id'].isdigit() and graph.get_monster(int(data['base_id']), server=self.server): + mid = int(data['base_id']) + for mod in data['modifiers'].split(","): + mod = mod.strip().lower() + if " " in mod: + self.multi_word_tokens.add(tuple(mod.split(" "))) + mod = mod.replace(" ", "") + aliases = get_modifier_aliases(mod) + for evomid in self.graph.get_alt_ids(self.graph.get_monster(mid, server=self.server)): + self.manual_modifiers[evomid].update(aliases) + + self._known_mods = {x for xs in self.series_id_to_pantheon_nickname.values() + for x in xs}.union(KNOWN_MODIFIERS) + + await self._build_monster_index(monsters) + + self.manual = combine_tokens_dicts(self.manual_cardnames, self.manual_treenames) + self.all_name_tokens = combine_tokens_dicts(self.manual, self.fluff_tokens, self.name_tokens) + self.all_modifiers = {p for ps in self.modifiers.values() for p in ps} + self.suffixes = LEGAL_END_TOKENS + self.mwt_to_len = defaultdict(lambda: 1, {"".join(mw): len(mw) for mw in self.multi_word_tokens}) + + self.is_ready.set() + + async def _build_monster_index(self, monsters): + async for m in AsyncIter(monsters): + self.modifiers[m] = await self.get_modifiers(m) + + # ID + self.manual_cardnames[str(m.monster_no_na)].add(m) + if m.monster_id > 10000: + self.manual_cardnames[str(m.monster_id)].add(m) + if m.monster_no_na != m.monster_no_jp: + self.name_tokens['na' + str(m.monster_no_na)].add(m) + self.name_tokens['jp' + str(m.monster_no_jp)].add(m) + + # Name Tokens + nametokens = self._name_to_tokens(m.name_en) + last_token = m.name_en.split(',')[-1].strip() + alt_monsters = self.graph.get_alt_monsters(m) + autotoken = len([me for me in alt_monsters if not me.is_equip]) > 1 + + for jpt in m.name_ja.split(" "): + self.name_tokens[jpt].add(m) + + # Propagate name tokens throughout all evos + for me in alt_monsters: + for t in self.monster_id_to_name[me.monster_id]: + if t in nametokens: + self.add_name_token(t, m) + if me.is_equip or contains_ja(me.name_en): + continue + if last_token != me.name_en.split(',')[-1].strip(): + autotoken = False + + # Find likely treenames + treenames = set() + regexes = [ + r"(?:Awoken|Reincarnated) (.*)", + r".*, (.*'s Gem)", + ] + for me in alt_monsters: + for r in regexes: + match = re.match(r, me.name_en) + if match: + treenames.add(match.group(1)) + + # Add important tokens + for token in self.monster_id_to_name[m.monster_id]: + self.add_name_token(token, m) + if m.monster_id in self.treename_overrides: + pass + elif autotoken: + # Add a consistant last token as important token + for token in self._name_to_tokens(m.name_en.split(',')[-1].strip()): + self.add_name_token(token, m) + else: + # Add name tokens by guessing which ones are important + for token in self._get_important_tokens(m.name_en, treenames) + self._name_to_tokens(m.roma_subname): + self.add_name_token(token, m) + if m.is_equip: + possessives = re.findall(r"(\w+)'s", m.name_en.lower()) + for mevo in alt_monsters: + for token2 in possessives: + if token2 in self._name_to_tokens(mevo.name_en.lower()) \ + and token2 + "'s" not in self._name_to_tokens(mevo.name_en.lower()): + self.add_name_token(token2, mevo) + else: + for mevo in alt_monsters: + if token in self._name_to_tokens(mevo.name_en): + self.add_name_token(token, mevo) + + # For equips only, add every name token from every other non-equip monster in the tree. + # This has the effect of making automated name tokens behave slightly more like treenames + # as opposed to nicknames, but only when dealing with equips, and is valuable so that we get + # the moving-through-tree effect with higher priority, but without having to add + # significantly more complicated logic in the lookup later on. + # Test case: Mizutsune is a nickname for Dark Aurora, ID 4148. Issue: #614 + if m.is_equip: + for mevo in alt_monsters: + if not mevo.is_equip: + for token2 in self._get_important_tokens(mevo.name_en, treenames): + if token2 not in HAZARDOUS_IN_NAME_MODS: + self.add_name_token(token2, m) + + # Fluff tokens + for token in nametokens + list(self.monster_id_to_forcedfluff[m.monster_id]): + if m in self.name_tokens[token.lower()]: + continue + self.add_fluff_token(token, m) + + # Monster Nickname + for nick in self.monster_id_to_cardname[m.monster_id]: + self.add_manual_nick_token(nick, m) + + # Tree Nickname + base_id = self.graph.get_base_id(m) + for nick in self.monster_id_to_treename[base_id]: + self.add_manual_tree_token(nick, m) + + def add_name_token(self, token, monster): + if monster in self.fluff_tokens[token.lower()]: + self.fluff_tokens[token.lower()].remove(monster) + self._add_content_token(self.name_tokens, token, monster) + + def add_fluff_token(self, token, monster): + self._add_content_token(self.fluff_tokens, token, monster) + + def add_manual_nick_token(self, token, monster): + self._add_content_token(self.manual_cardnames, token, monster) + + def add_manual_tree_token(self, token, monster): + self._add_content_token(self.manual_treenames, token, monster) + + def _add_content_token(self, token_dict, token, m, depth=5): + if depth <= 0: + logger.warning(f"Depth exceeded with token {token}. Aborting.") + return + + token_dict[token.lower()].add(m) + if token.lower() in self._known_mods and token.lower() not in HAZARDOUS_IN_NAME_MODS: + self.modifiers[m].add(token.lower()) + + # Replacements + for ts in (k for k in self.content_token_aliases if token.lower() in k and all(m in token_dict[t] for t in k)): + for t in self.content_token_aliases[ts]: + self._add_content_token(token_dict, t, m, depth - 1) + + @staticmethod + def _name_to_tokens(oname): + if not oname: + return [] + oname = oname.lower().replace(',', '') + name = re.sub(r'[\-+\']', ' ', oname) + name = re.sub(r'[^a-z0-9 ]', '', name) + return [t.strip() for t in set(name.split() + oname.split()) if t] + + @classmethod + def _get_important_tokens(cls, oname, treenames=None): + if treenames is None: + treenames = set() + + if contains_ja(oname): + return list(treenames) + + name = oname.split(", ") + if len(name) == 1: + return cls._name_to_tokens(oname) + *n1, n2 = name + n1 = ", ".join(n1) + if treenames.intersection((n1, n2)): + return [t for n in treenames.intersection((n1, n2)) for t in cls._name_to_tokens(n)] + elif token_count(n1) == token_count(n2) or max(token_count(n1), token_count(n2)) < 3: + return cls._name_to_tokens(oname) + else: + return cls._name_to_tokens(min(n1, n2, key=token_count)) + + async def get_modifiers(self, monster: MonsterModel): + modifiers = self.manual_modifiers[monster.monster_id].union({'monster'}) + + basemon = self.graph.get_base_monster(monster) + + # Main Color + modifiers.update(COLOR_MAP[monster.attr1]) + + # Sub Color + modifiers.update(SUB_COLOR_MAP[monster.attr2]) + if monster.attr1.value == 6: + modifiers.update(COLOR_MAP[monster.attr2]) + + # Both Colors + modifiers.update(DUAL_COLOR_MAP[(monster.attr1, monster.attr2)]) + + # Type + for mt in monster.types: + modifiers.update(TYPE_MAP[mt]) + + # Series + for series in monster.all_series: + modifiers.add("series" + str(series.series_id)) + if series.series_id in self.series_id_to_pantheon_nickname: + modifiers.update(self.series_id_to_pantheon_nickname[series.series_id]) + + # Group + modifiers.add("_group" + str(monster.group_id)) + + # Rarity + modifiers.add(str(monster.rarity) + "*") + modifiers.add(str(basemon.rarity) + "*b") + + # Base + if self.graph.monster_is_base(monster): + modifiers.update(EVO_MAP[EvoTypes.BASE]) + + special_evo = ('覚醒' in monster.name_ja or 'awoken' in monster.name_en or '転生' in monster.name_ja or + self.graph.true_evo_type(monster).value == "Reincarnated" or + 'reincarnated' in monster.name_en or + self.graph.true_evo_type(monster).value == "Super Reincarnated" or + monster.is_equip or '極醒' in monster.name_ja) + + # Evo + self.add_numbered_modifier(monster, modifiers, EVO_MAP[EvoTypes.EVO], + lambda m: (self.graph.monster_is_normal_evo(m) + or self.graph.monster_is_first_evo(m))) + + # Uvo + self.add_numbered_modifier(monster, modifiers, EVO_MAP[EvoTypes.UVO], + lambda m: (self.graph.monster_is_reversible_evo(m) + and not special_evo)) + + # UUvo + self.add_numbered_modifier(monster, modifiers, EVO_MAP[EvoTypes.UUVO], + self.graph.monster_is_second_ultimate) + + # Transform + self.add_numbered_modifier(monster, modifiers, EVO_MAP[EvoTypes.TRANS], + lambda m: not self.graph.monster_is_transform_base(m)) + self.add_numbered_modifier(monster, modifiers, EVO_MAP[EvoTypes.BASETRANS], + lambda m: (self.graph.monster_is_transform_base(m) + and self.graph.get_next_transforms(m))) + + # Awoken + self.add_numbered_modifier(monster, modifiers, EVO_MAP[EvoTypes.AWOKEN], + lambda m: '覚醒' in m.name_ja or 'awoken' in m.name_en.lower()) + + # Mega Awoken + self.add_numbered_modifier(monster, modifiers, EVO_MAP[EvoTypes.MEGA], + lambda m: '極醒' in m.name_ja or 'mega awoken' in m.name_en.lower()) + + # Reincarnated + self.add_numbered_modifier(monster, modifiers, EVO_MAP[EvoTypes.REVO], + lambda m: self.graph.true_evo_type(m).value == "Reincarnated") + + # Super Reincarnated + self.add_numbered_modifier(monster, modifiers, EVO_MAP[EvoTypes.SREVO], + lambda m: self.graph.true_evo_type(m).value == "Super Reincarnated") + + # Pixel + self.add_numbered_modifier(monster, modifiers, EVO_MAP[EvoTypes.PIXEL], + lambda m: (m.name_ja.startswith('ドット') or m.name_en.startswith('pixel') + or self.graph.true_evo_type(m).value == "Pixel"), + else_mods=EVO_MAP[EvoTypes.NONPIXEL]) + # Depth + modifiers.add(f"{int(self.graph.get_monster_depth(monster))}depth") + + # Awakenings + for aw in monster.awakenings: + try: + modifiers.update(AWOKEN_SKILL_MAP[AwokenSkills(aw.awoken_skill_id)]) + except ValueError: + logger.warning(f"Invalid awoken skill ID: {aw.awoken_skill_id}") + self.issues.append(f"Invalid awoken skill ID: {aw.awoken_skill_id}") + + # Equips + self.add_numbered_modifier(monster, modifiers, EVO_MAP[EvoTypes.EQUIP], + lambda m: m.is_equip) + + # Chibi + self.add_numbered_modifier(monster, modifiers, EVO_MAP[EvoTypes.CHIBI], + lambda m: (m.name_en == m.name_en.lower() and m.name_en != m.name_ja + or 'ミニ' in m.name_ja or '(chibi)' in m.name_en)) + + # Series Type + if monster.series.series_type == 'regular': + modifiers.update(MISC_MAP[MiscModifiers.REGULAR]) + if monster.series.series_type == 'event': + modifiers.update(MISC_MAP[MiscModifiers.EVENT]) + if monster.series.series_type == 'seasonal': + modifiers.update(MISC_MAP[MiscModifiers.SEASONAL]) + if monster.series.series_type in ('collab', 'ghcollab'): + modifiers.update(MISC_MAP[MiscModifiers.COLLAB]) + + # Story + def is_story(m, do_transform=True): + if any(s.series_id == 196 for s in m.all_series) \ + or any(s.series_id == 196 for mat in self.graph.evo_mats(m) for s in mat.all_series): + return True + if do_transform: + for pt in self.graph.get_transform_monsters(m): + if is_story(pt, False): + return True + pe = self.graph.get_prev_evolution(m) + if pe and is_story(pe): + return True + return False + + if is_story(monster): + modifiers.update(MISC_MAP[MiscModifiers.STORY]) + + # GFE Shop + if monster.in_rem: + if self.graph.monster_is_gfe_exchange(monster): + modifiers.update(MISC_MAP[MiscModifiers.GFESHOP]) + if self.graph.monster_is_gfe_exchange(monster, 6): + modifiers.update(MISC_MAP[MiscModifiers.GFESHOP6S]) + if self.graph.monster_is_gfe_exchange(monster, 7): + modifiers.update(MISC_MAP[MiscModifiers.GFESHOP7S]) + + # New + if self.graph.monster_is_new(monster): + modifiers.update(MISC_MAP[MiscModifiers.NEW]) + + # Method of Obtaining + if self.graph.monster_is_farmable_evo(monster) or self.graph.monster_is_mp_evo(monster): + modifiers.update(MISC_MAP[MiscModifiers.FARMABLE]) + if self.graph.monster_is_pem_evo(monster): + modifiers.update(MISC_MAP[MiscModifiers.PEM]) + if self.graph.monster_is_vem_evo(monster): + modifiers.update(MISC_MAP[MiscModifiers.ADPEM]) + if monster.in_vem: + modifiers.update(MISC_MAP[MiscModifiers.INADPEM]) + + if self.graph.monster_is_rem_evo(monster): + modifiers.update(MISC_MAP[MiscModifiers.REM]) + else: + try: + if self.graph.monster_is_vendor_exchange(monster): + modifiers.update(MISC_MAP[MiscModifiers.MEDAL_EXC]) + if self.graph.monster_is_black_medal_exchange_evo(monster): + modifiers.update(MISC_MAP[MiscModifiers.BLACK_MEDAL]) + if self.graph.monster_is_currently_exchangable_evo(monster, Server.JP): + modifiers.update(MISC_MAP[MiscModifiers.CURRENT_EXCHANGE_JP]) + if self.graph.monster_is_currently_exchangable_evo(monster, Server.NA): + modifiers.update(MISC_MAP[MiscModifiers.CURRENT_EXCHANGE_NA]) + if self.graph.monster_is_currently_exchangable_evo(monster, Server.KR): + modifiers.update(MISC_MAP[MiscModifiers.CURRENT_EXCHANGE_KR]) + if self.graph.monster_is_permanent_exchange_evo(monster): + modifiers.update(MISC_MAP[MiscModifiers.PERMANENT_EXCHANGE]) + else: + modifiers.update(MISC_MAP[MiscModifiers.TEMP_EXCHANGE]) + + except InvalidGraphState: + pass + if self.graph.monster_is_mp_evo(monster): + modifiers.update(MISC_MAP[MiscModifiers.MP]) + + if monster.sell_mp < 100: + modifiers.update(MISC_MAP[MiscModifiers.TRADEABLE]) + + # Art + if self.graph.monster_is_orb_skin_evo(monster): + modifiers.update(MISC_MAP[MiscModifiers.ORBSKIN]) + if self.graph.monster_is_bgm_evo(monster): + modifiers.update(MISC_MAP[MiscModifiers.BGM]) + if monster.has_animation: + modifiers.update(MISC_MAP[MiscModifiers.ANIMATED]) + + # Server + if monster.on_jp: + modifiers.update(MISC_MAP[MiscModifiers.INJP]) + if not monster.on_na: + modifiers.update(MISC_MAP[MiscModifiers.ONLYJP]) + if monster.on_na: + modifiers.update(MISC_MAP[MiscModifiers.INNA]) + if not monster.on_jp: + modifiers.update(MISC_MAP[MiscModifiers.ONLYNA]) + if monster.monster_id + 10000 in self.graph.graph_dict[monster.server_priority].nodes: + modifiers.add("idjp") + if monster.monster_id > 10000: + modifiers.add("idna") + + # Has Gem + if self.graph.evo_gem_monster(monster) is not None: + modifiers.update(MISC_MAP[MiscModifiers.HAS_GEM]) + + modifiers.difference_update(self.manual_removed_modifiers[monster.monster_id]) + + return modifiers + + def add_numbered_modifier(self, monster, curr_mods, added_mods, condition, *, else_mods=None): + if condition(monster): + curr_mods.update(added_mods) + ms = [m for m in self.graph.get_alt_monsters(monster) if condition(m)] + if len(ms) > 1: + curr_mods.update(f'{mod}-{ms.index(monster) + 1}' for mod in added_mods) + elif else_mods: + curr_mods.update(else_mods) + + +async def sheet_to_reader(url, headers) -> List[Dict[str, str]]: + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + reader = csv.reader(io.StringIO(await response.text()), delimiter=',') + next(reader) + return [dict(zip(headers, line[:len(headers)])) for line in reader] + + +def copydict(token_dict): + copy = defaultdict(set) + for k, v in token_dict.items(): + copy[k] = v.copy() + return copy + + +def combine_tokens_dicts(d1, *ds): + combined = defaultdict(set, d1.copy()) + for d2 in ds: + for k, v in d2.items(): + combined[k] = combined[k].union(v) + return combined + + +def token_count(tstr): + tstr = re.sub(r"\(.+\)", "", tstr) + return len(re.split(r'\W+', tstr)) + + +def get_modifier_aliases(mod): + output = {mod} + for mods in ALL_TOKEN_DICTS: + if mod in mods: + output.update(mods) + return output diff --git a/donations/__init__.py b/donations/__init__.py deleted file mode 100644 index ee75fcb4..00000000 --- a/donations/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .donations import * - - -def setup(bot): - bot.add_cog(Donations(bot)) diff --git a/donations/donations.py b/donations/donations.py deleted file mode 100644 index 273347bb..00000000 --- a/donations/donations.py +++ /dev/null @@ -1,496 +0,0 @@ -import json -import random -import re - -import discord -from redbot.core import checks -from redbot.core import commands -from redbot.core.bot import Red -from redbot.core.utils.chat_formatting import inline, box - -from rpadutils import CogSettings, clean_global_mentions - -DONATE_MSG = """ -To donate to cover bot hosting fees you can use one of: - Patreon : https://www.patreon.com/miru_bot - Venmo : https://venmo.com/TacticalRetreat - -Read the Patreon or join the Miru Support Server for more details: - https://discord.gg/zB4QHgn - -You permanently get some special perks for donating even $1. - -The following users have donated. Thanks! -{donors} -""" - -INSULTS_FILE = "data/donations/insults.json" -DEFAULT_INSULTS = { - 'miru_references': [ - 'Are you talking to me you piece of shit?', - ], - 'insults': [ - 'You are garbage.', - 'Kill yourself.', - ] -} -LOVE_FILE = "data/donations/love.json" -DEFAULT_LOVE = { - 'cute': ['xoxo'], - 'sexy': ['{}====>'], - 'perverted': ['{}===>()'], -} - - -def roll(chance: int): - return random.randrange(100) < chance - - -class Donations(commands.Cog): - """Manages donations and perks.""" - - def __init__(self, bot: Red, *args, **kwargs): - super().__init__(*args, **kwargs) - self.bot = bot - self.settings = DonationsSettings("donations") - - try: - insults_json = json.load(open(INSULTS_FILE, "r")) - except: - insults_json = {} - self.insults_miru_reference = insults_json.get( - 'miru_references', DEFAULT_INSULTS['miru_references']) - self.insults_list = insults_json.get('insults', DEFAULT_INSULTS['insults']) - try: - love_json = json.load(open(LOVE_FILE, "r")) - except: - love_json = {} - self.cute_list = love_json.get('cute', DEFAULT_LOVE['cute']) - self.sexy_list = love_json.get('sexy', DEFAULT_LOVE['sexy']) - self.perverted_list = love_json.get('perverted', DEFAULT_LOVE['perverted']) - - @commands.command() - async def donate(self, ctx): - """Prints information about donations.""" - donors = self.settings.donors() - donor_names = set() - for user in self.bot.get_all_members(): - if user.id in donors: - donor_names.add(user.name) - - msg = DONATE_MSG.format(count=len(donors), donors=', '.join(sorted(donor_names))) - await ctx.send(box(msg)) - - @commands.command() - async def mycommand(self, ctx, command: str, *, text: str): - """Sets your custom command (donor only).""" - user_id = ctx.author.id - text = clean_global_mentions(text) - if user_id not in self.settings.donors(): - await ctx.send(inline('Only donors can set a personal command')) - return - - self.settings.addCustomCommand(user_id, command, text) - await ctx.send(inline('I set up your command: ' + command)) - - @commands.command() - async def myembed(self, ctx, command: str, title: str, url: str, footer: str): - """Sets your custom embed command (donor only). - - This lets you create a fancier image message. For example you can set up - a simple inline image without a link using: - [p]myembed lewd "" "http://i0.kym-cdn.com/photos/images/original/000/731/885/751.jpg" "" - - Want a title on that image? Fill in the first argument: - [p]myembed lewd "L-lewd!" "" "" - - Want a footer? Fill in the last argument: - [p]myembed lewd "L-lewd!" "" "source: some managa i read" - """ - user_id = ctx.author.id - if user_id not in self.settings.donors(): - await ctx.send(inline('Only donors can set a personal command')) - return - - self.settings.addCustomEmbed(user_id, command, title, url, footer) - await ctx.send(inline('I set up your embed: ' + command)) - - @commands.command() - async def spankme(self, ctx): - """You are trash (donor only).""" - user_id = ctx.author.id - if user_id not in self.settings.donors(): - await ctx.send(inline('Donor-only command')) - return - - await ctx.send(ctx.author.mention + ' ' + random.choice(self.insults_list)) - - @commands.command() - async def insultme(self, ctx): - """You are consistently trash (donor only).""" - user_id = ctx.author.id - if user_id not in self.settings.donors(): - await ctx.send(inline('Donor-only command')) - return - - self.settings.addInsultsEnabled(user_id) - await ctx.send(ctx.author.mention + ' ' 'Oh, I will.\n' + random.choice(self.insults_list)) - - @commands.command(hidden=True) - async def insultripper(self, ctx): - """Fuck ripper.""" - ripper_id = '123529484476350467' - ripper = ctx.guild.get_member(ripper_id) - insult = random.choice(self.insults_list) - if ripper: - await ctx.send(ripper.mention + ' ' + insult) - else: - await ctx.send('Ripper is not in this server but I let him know anyway') - ripper = discord.utils.get(self.bot.get_all_members(), id=ripper_id) - await ripper.send('{} asked me to send you this:\n{}'.format(ctx.author.name, insult)) - - @commands.command() - async def vcinsultripper(self, ctx): - """Fuck ripper (verbally)""" - user_id = ctx.author.id - if user_id not in self.settings.donors(): - await ctx.send(inline('Donor-only command')) - return - - ripper_id = '123529484476350467' - ripper = ctx.guild.get_member(ripper_id) - - if ripper is None: - await ctx.send(inline('Ripper must be in this server to use this command')) - - voice = ripper.voice - if not voice: - await ctx.send(inline('Ripper must be in a voice channel on this server to use this command')) - return - - channel = voice.channel - - insult = random.choice(self.insults_list) - speech_cog = self.bot.get_cog('Speech') - if not speech_cog: - await ctx.send(inline('Speech seems to be offline')) - return - - await ctx.send(ripper.mention + ' ' + insult) - await speech_cog.speak(channel, 'Hey Ripper, ' + insult) - - @commands.command() - async def plsno(self, ctx): - """I am merciful (donor only).""" - user_id = ctx.author.id - if user_id not in self.settings.donors(): - await ctx.send(inline('Donor-only command')) - return - - self.settings.rmInsultsEnabled(user_id) - await ctx.send('I will let you off easy this time.') - - @commands.command() - async def kissme(self, ctx): - """You are so cute! (donor only).""" - user_id = ctx.author.id - if user_id not in self.settings.donors(): - await ctx.send(inline('Donor-only command')) - return - - await ctx.send(ctx.author.mention + ' ' + random.choice(self.cute_list)) - - @commands.command() - async def lewdme(self, ctx): - """So nsfw. (donor only).""" - user_id = ctx.author.id - if user_id not in self.settings.donors(): - await ctx.send(inline('Donor-only command')) - return - - if 'nsfw' in ctx.channel.name.lower(): - await ctx.send(ctx.author.mention + ' ' + random.choice(self.sexy_list)) - else: - await ctx.send(ctx.author.mention + ' Oooh naughty...') - await ctx.author.send(random.choice(self.sexy_list)) - - @commands.command() - async def pervme(self, ctx): - """Hentai!!! (donor only).""" - user_id = ctx.author.id - if user_id not in self.settings.donors(): - await ctx.send(inline('Donor-only command')) - return - - if 'nsfw' in ctx.channel.name.lower(): - await ctx.send(ctx.author.mention + ' ' + random.choice(self.perverted_list)) - else: - await ctx.send(ctx.author.mention + ' Filthy hentai!') - await ctx.author.send(random.choice(self.perverted_list)) - - @commands.group() - @checks.admin_or_permissions(manage_guild=True) - async def donations(self, ctx): - """Manage donation options.""" - - @donations.command() - @checks.admin_or_permissions(manage_guild=True) - async def togglePerks(self, ctx): - """Enable or disable donor-specific perks for the server.""" - server_id = ctx.guild.id - if server_id in self.settings.disabledServers(): - self.settings.rmDisabledServer(server_id) - await ctx.send(inline('Donor perks enabled on this server')) - else: - self.settings.addDisabledServer(server_id) - await ctx.send(inline('Donor perks disabled on this server')) - - @donations.command() - @checks.is_owner() - async def addDonor(self, ctx, user: discord.User): - """Adds a a user as a donor.""" - self.settings.addDonor(user.id) - await ctx.send(inline('Done')) - - @donations.command() - @checks.is_owner() - async def rmDonor(self, ctx, user: discord.User): - """Removes a user as a donor.""" - self.settings.rmDonor(user.id) - await ctx.send(inline('Done')) - - @donations.command() - @checks.is_owner() - async def addPatron(self, ctx, user: discord.User): - """Adds a a user as a patron.""" - self.settings.addPatron(user.id) - await ctx.send(inline('Done')) - - @donations.command() - @checks.is_owner() - async def rmPatron(self, ctx, user: discord.User): - """Removes a user as a patron.""" - self.settings.rmPatron(user.id) - await ctx.send(inline('Done')) - - @donations.command() - @checks.is_owner() - async def info(self, ctx): - """Print donation related info.""" - patrons = self.settings.patrons() - donors = self.settings.donors() - cmds = self.settings.customCommands() - embeds = self.settings.customEmbeds() - disabled_servers = self.settings.disabledServers() - - id_to_name = {m.id: m.name for m in self.bot.get_all_members()} - - msg = 'Donations Info' - - msg += '\n\nPatrons:' - for user_id in patrons: - msg += '\n\t{} ({})'.format(id_to_name.get(user_id, 'unknown'), user_id) - - msg += '\n\nDonors:' - for user_id in donors: - msg += '\n\t{} ({})'.format(id_to_name.get(user_id, 'unknown'), user_id) - - msg += '\n\nDisabled servers:' - for server_id in disabled_servers: - server = self.bot.get_guild(int(server_id)) - msg += '\n\t{} ({})'.format(server.name if server else 'unknown', server_id) - - msg += '\n\n{} personal commands are set'.format(len(cmds)) - msg += '\n{} personal embeds are set'.format(len(cmds)) - - await ctx.send(box(msg)) - - @commands.Cog.listener("on_message") - async def checkCC(self, message): - if len(message.content) < 2: - return - - prefix = (await self.bot.get_prefix(message))[0] - - user_id = message.author.id - if user_id not in self.settings.donors(): - return - - if message.guild and message.guild.id in self.settings.disabledServers(): - return - - user_cmd = self.settings.customCommands().get(user_id) - user_embed = self.settings.customEmbeds().get(user_id) - - cmd = message.content[len(prefix):].lower() - if user_cmd is not None: - if cmd == user_cmd['command']: - await message.channel.send(user_cmd['text']) - return - if user_embed is not None: - if cmd == user_embed['command']: - embed = discord.Embed() - title = user_embed['title'] - url = user_embed['url'] - footer = user_embed['footer'] - if len(title): - embed.title = title - if len(url): - embed.set_image(url=url) - if len(footer): - embed.set_footer(text=footer) - await message.channel.send(embed=embed) - return - - @commands.Cog.listener("on_message") - async def check_insult(self, message): - # Only opted-in people - if message.author.id not in self.settings.insultsEnabled(): - return - - if message.guild and message.guild.id in self.settings.disabledServers(): - return - - content = message.clean_content - # Ignore short messages - if len(content) < 10: - return - - msg = message.author.mention - - # Pretty frequently respond to direct messages - mentions_bot = re.search(r'(miru|myr) bot', content, re.IGNORECASE) and roll(40) - # Semi-frequently respond to miru in msg - mentions_miru_and_roll = re.search( - r'\b(miru|myr)\b', content, re.IGNORECASE) and roll(20) - - if mentions_bot or mentions_miru_and_roll: - msg += ' ' + random.choice(self.insults_miru_reference) - msg += '\n' + random.choice(self.insults_list) - await message.channel.send(msg) - return - - # Semi-frequently respond to long messages - long_msg_and_roll = len(content) > 200 and roll(10) - # Occasionally respond to other messages - short_msg_and_roll = roll(1) - - if long_msg_and_roll or short_msg_and_roll: - msg += ' ' + random.choice(self.insults_list) - await message.channel.send(msg) - return - - # Periodically send private messages - if roll(7): - msg += ' ' + random.choice(self.insults_list) - await message.author.send(msg) - return - - -class DonationsSettings(CogSettings): - def make_default_settings(self): - config = { - 'patrons': [], - 'donors': [], - 'custom_commands': {}, - 'custom_embeds': {}, - 'disabled_servers': [], - 'insults_enabled': [], - } - return config - - def patrons(self): - return self.bot_settings['patrons'] - - def addPatron(self, user_id): - patrons = self.patrons() - if user_id not in patrons: - patrons.append(user_id) - self.save_settings() - - def rmPatron(self, user_id): - patrons = self.patrons() - if user_id in patrons: - patrons.remove(user_id) - self.save_settings() - - def donors(self): - return self.bot_settings['donors'] - - def addDonor(self, user_id): - donors = self.donors() - if user_id not in donors: - donors.append(user_id) - self.save_settings() - - def rmDonor(self, user_id): - donors = self.donors() - if user_id in donors: - donors.remove(user_id) - self.save_settings() - - def customCommands(self): - return self.bot_settings['custom_commands'] - - def addCustomCommand(self, user_id, command, text): - cmds = self.customCommands() - cmds[user_id] = { - 'command': command.lower(), - 'text': text, - } - self.save_settings() - - def rmCustomCommand(self, user_id): - cmds = self.customCommands() - if user_id in cmds: - cmds.remove(user_id) - self.save_settings() - - def customEmbeds(self): - return self.bot_settings['custom_embeds'] - - def addCustomEmbed(self, user_id, command, title, url, footer): - embeds = self.customEmbeds() - embeds[user_id] = { - 'command': command.lower().strip(), - 'title': title.strip(), - 'url': url.strip(), - 'footer': footer.strip(), - } - self.save_settings() - - def rmCustomEmbed(self, user_id): - embeds = self.customEmbeds() - if user_id in embeds: - embeds.remove(user_id) - self.save_settings() - - def disabledServers(self): - return self.bot_settings['disabled_servers'] - - def addDisabledServer(self, server_id): - disabled_servers = self.disabledServers() - if server_id not in disabled_servers: - disabled_servers.append(server_id) - self.save_settings() - - def rmDisabledServer(self, server_id): - disabled_servers = self.disabledServers() - if server_id in disabled_servers: - disabled_servers.remove(server_id) - self.save_settings() - - def insultsEnabled(self): - return self.bot_settings['insults_enabled'] - - def addInsultsEnabled(self, user_id): - insults_enabled = self.insultsEnabled() - if user_id not in insults_enabled: - insults_enabled.append(user_id) - self.save_settings() - - def rmInsultsEnabled(self, user_id): - insults_enabled = self.insultsEnabled() - if user_id in insults_enabled: - insults_enabled.remove(user_id) - self.save_settings() diff --git a/dungeoncog/__init__.py b/dungeoncog/__init__.py new file mode 100644 index 00000000..fc030430 --- /dev/null +++ b/dungeoncog/__init__.py @@ -0,0 +1,14 @@ +import json +from pathlib import Path + +from .dungeoncog import DungeonCog + +with open(Path(__file__).parent / "info.json") as file: + __red_end_user_data_statement__ = json.load(file)['end_user_data_statement'] + + +async def setup(bot): + n = DungeonCog(bot) + bot.add_cog(n) if not __import__('asyncio').iscoroutinefunction(bot.add_cog) else await bot.add_cog(n) + bot.loop.create_task(n.register_menu()) + bot.loop.create_task(n.load_emojis()) diff --git a/dungeoncog/dungeon_monster.py b/dungeoncog/dungeon_monster.py new file mode 100644 index 00000000..32a901bb --- /dev/null +++ b/dungeoncog/dungeon_monster.py @@ -0,0 +1,30 @@ +from typing import List + +from dungeoncog.grouped_skillls import GroupedSkills + + +class DungeonMonster(object): + """ + A class that symbolizes an encounter in a dungeon. Why is it not a model? Good question. + """ + + def __init__(self, hp, atk, defense, turns, level, monster, error=None): + self.hp = hp + self.atk = atk + self.defense = defense + self.turns = turns + self.level = level + self.groups: List[GroupedSkills] = [] + self.am_invade = False + self.monster = monster + + self.error = error + + def add_group(self, group: GroupedSkills): + self.groups.append(group) + + async def collect_skills(self): + skills = [] + for g in self.groups: + skills.extend(await g.collect_skills()) + return skills diff --git a/dungeoncog/dungeoncog.py b/dungeoncog/dungeoncog.py new file mode 100644 index 00000000..73632198 --- /dev/null +++ b/dungeoncog/dungeoncog.py @@ -0,0 +1,162 @@ +import logging +from typing import Any, TYPE_CHECKING + +from discordmenu.emoji.emoji_cache import emoji_cache +from redbot.core import commands +from redbot.core.utils.chat_formatting import pagify +from tsutils.enums import Server +from tsutils.user_interaction import send_cancellation_message + +from dungeoncog.enemy_skills_pb2 import MonsterBehavior +from dungeoncog.menu.dungeon import DungeonMenu +from dungeoncog.menu.menu_map import dungeon_menu_map +from dungeoncog.view.dungeon import DungeonViewState + +if TYPE_CHECKING: + from dbcog.dungeon_context import DungeonContext + +logger = logging.getLogger('red.padbot-cogs.dungeoncog') +EMBED_NOT_GENERATED = -1 +DEFAULT_SERVER = Server.COMBINED + + +class DungeonCog(commands.Cog): + """ + Contains commands that are display information about dungeons. + Right now only one command, dungeon_info which displays information such as spawns in a dungeon. + """ + menu_map = dungeon_menu_map + + def __init__(self, bot, *args, **kwargs): + super().__init__(*args, **kwargs) + self.bot = bot + + gadmin: Any = self.bot.get_cog("GlobalAdmin") + if gadmin: + gadmin.register_perm("contentadmin") + + async def load_emojis(self): + await self.bot.wait_until_ready() + emoji_cache.set_guild_ids([g.id for g in self.bot.guilds]) + emoji_cache.refresh_from_discord_bot(self.bot) + + async def register_menu(self): + await self.bot.wait_until_ready() + menulistener = self.bot.get_cog("MenuListener") + if menulistener is None: + logger.warning("MenuListener is not loaded.") + return + await menulistener.register(self) + + async def get_menu_default_data(self, ims): + data = { + 'dbcog': await self.get_dbcog(), + } + return data + + async def get_dbcog(self): + dbcog = self.bot.get_cog("DBCog") + if dbcog is None: + raise ValueError("DBCog cog is not loaded") + await dbcog.wait_until_ready() + return dbcog + + async def find_dungeon_from_name(self, ctx, name, database: "DungeonContext", difficulty: str = None, + server: Server = DEFAULT_SERVER): + """ + Gets the sub_dungeon model given the name of a dungeon and its difficulty. + """ + dungeons = database.get_dungeons_from_nickname(name.lower(), server=server) + if not dungeons: + dungeons = database.get_dungeons_from_name(name, server=server) + if len(dungeons) == 0: + return None + if len(dungeons) > 1: + return dungeons + dungeon = dungeons.pop() + if server is not None: + # slightly bullshit handling here because we can't have DEFAULT_SERVER imported + # here due to cross-cog bullshit. + # please move DEFAULT_SERVER to tsutils or do a proper cross-cog import + # for a proper patch but this is a hotfix okay + sub_id = database.get_sub_dungeon_id_from_name(dungeon.dungeon_id, difficulty, server=server) + else: + sub_id = database.get_sub_dungeon_id_from_name(dungeon.dungeon_id, difficulty) + sub_dungeon_model = None + if sub_id is None: + sub_id = 0 + for sd in dungeon.sub_dungeons: + if sd.sub_dungeon_id > sub_id: + sub_id = sd.sub_dungeon_id + sub_dungeon_model = sd + else: + for sd in dungeon.sub_dungeons: + if sd.sub_dungeon_id == sub_id: + sub_dungeon_model = sd + break + dungeon.sub_dungeons = [sub_dungeon_model] + else: + dungeon = dungeons.pop() + return dungeon + + @commands.command(aliases=['dgid'], ignore_extra=False) + async def dungeonid(self, ctx, dungeon_name, floor_name=None): + """Get encounter data for a dungeon. + + Quotes must be used around multi-word names + [p]dungeonid "blue bowl dragon" + [p]dungeonid "ultimate arena" "three hands of fate" + [p]dungeonid "castle of satan in the abyss" 3 + """ + dbcog = await self.get_dbcog() + dungeon = await self.find_dungeon_from_name(ctx, dungeon_name, dbcog.database.dungeon, floor_name) + + if dungeon is None: + return await send_cancellation_message(ctx, "No dungeons found!") + if type(dungeon) == list: + header = "Multiple Dungeons Found, please be more specific:\n" + for page in pagify(header + '\n'.join(d.name_en for d in dungeon)): + await ctx.send(page) + return + # await ctx.send(format_overview(test_result)) + current_stage = 0 + pm_dungeon = [] + # check for invades: + invades = [] + + # print(dungeon.sub_dungeons[0].encounter_models) + for enc_model in dungeon.sub_dungeons[0].encounter_models: + behavior_test = MonsterBehavior() + if (enc_model.enemy_data is not None) and (enc_model.enemy_data.behavior is not None): + behavior_test.ParseFromString(enc_model.enemy_data.behavior) + else: + behavior_test = None + + # await ctx.send(format_overview(test_result)) + # pm = process_monster(behavior_test, enc_model, db_cog.database) + if enc_model.stage < 0: + # pm.am_invade = True + invades.append(enc_model) + elif enc_model.stage > current_stage: + current_stage = enc_model.stage + floor = [enc_model] + pm_dungeon.append(floor) + else: + pm_dungeon[current_stage - 1].append(enc_model) + for f in pm_dungeon: + if pm_dungeon.index(f) != (len(pm_dungeon) - 1): + f.extend(invades) + + if len(pm_dungeon) == 0: + return await send_cancellation_message(ctx, "This dungeon exists, but we have no data for it") + + menu = DungeonMenu.menu() + original_author_id = ctx.message.author.id + view_state = DungeonViewState(original_author_id, 'DungeonMenu', dungeon_name, pm_dungeon[0][0], + dungeon.sub_dungeons[0].sub_dungeon_id, len(pm_dungeon), 1, + len(pm_dungeon[0]), 0, + int(dungeon.sub_dungeons[0].technical), dbcog.database, verbose=False) + await ctx.send( + "EN: {}({})\nJP: {}({})".format(dungeon.name_en, dungeon.sub_dungeons[0].name_en, dungeon.name_ja, + dungeon.sub_dungeons[0].name_ja)) + await menu.create(ctx, view_state) diff --git a/dungeoncog/enemy_skill.py b/dungeoncog/enemy_skill.py new file mode 100644 index 00000000..4e6619b5 --- /dev/null +++ b/dungeoncog/enemy_skill.py @@ -0,0 +1,53 @@ +from typing import List + +from dungeoncog.grouped_skillls import GroupedSkills + + +class ProcessedSkill(object): + """ + This is a representation of a processed enemy skill. It contains the name, effect, + processed (emoji) version of the effect, condition requriements, parents, type (passive, preempt) + The class also contains a function called give_string. When called it returns the necessary strings needed to + display it in dungeon_info2 + """ + + def __init__(self, name: str, effect: str, processed: List[str], condition: str = None, + parent: GroupedSkills = None): + self.name = name + self.effect = effect + self.processed = processed + self.condition = condition + self.parent = parent + self.type = self.find_type() + self.is_passive_preempt = len(self.process_type()) != 0 + + def find_type(self): + """Helper function to determine whether the skill is a preempt/passive from the parent group""" + parent = self.parent + while parent is not None: + if parent.type is not None: + return parent.type + parent = parent.parent + return None + + def process_type(self): + if "Passive" in self.type or "Preemptive" in self.type: + return "({}".format(self.type) + return "" + + def give_string(self, indent: str = "", verbose: bool = False): + """When called returns the strings of a skill in the following format: [emoji, effect, condition]""" + components = [[], None, None] # skills (condensed), effect, condition + # if no skills/not processed just return (N/A) as the skill + if len(self.processed) == 0: + components[0].append("(N/A)") + return components + # For the emoji text we append the skill type to the beginning eg. (Emoji Effect) -> (Passive) (Emoji Effect) + components[0] = [self.process_type()] + components[0].extend(self.processed) + # If verbose information is requested (ie display Effect text) we provide it + if verbose: + components[1] = "E: {}".format(self.effect) + if self.condition is not None: + components[2] = "Cond: {}".format(self.condition) + return components diff --git a/dungeoncog/enemy_skills_pb2.py b/dungeoncog/enemy_skills_pb2.py new file mode 100644 index 00000000..d9c31005 --- /dev/null +++ b/dungeoncog/enemy_skills_pb2.py @@ -0,0 +1,635 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: enemy_skills.proto +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from google.protobuf import reflection as _reflection +from google.protobuf import symbol_database as _symbol_database + +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + +DESCRIPTOR = _descriptor.FileDescriptor( + name='enemy_skills.proto', + package='dadguide_proto', + syntax='proto3', + serialized_options=None, + create_key=_descriptor._internal_create_key, + serialized_pb=b'\n\x12\x65nemy_skills.proto\x12\x0e\x64\x61\x64guide_proto\"\xbf\x02\n\x1cMonsterBehaviorWithOverrides\x12\x12\n\nmonster_id\x18\x01 \x01(\x05\x12-\n\x06levels\x18\x02 \x03(\x0b\x32\x1d.dadguide_proto.LevelBehavior\x12\x36\n\x0flevel_overrides\x18\x03 \x03(\x0b\x32\x1d.dadguide_proto.LevelBehavior\x12\x43\n\x06status\x18\x04 \x01(\x0e\x32\x33.dadguide_proto.MonsterBehaviorWithOverrides.Status\"_\n\x06Status\x12\x10\n\x0cNOT_APPROVED\x10\x00\x12\x12\n\x0e\x41PPROVED_AS_IS\x10\x01\x12\x14\n\x10NEEDS_REAPPROVAL\x10\x02\x12\x19\n\x15\x41PPROVED_WITH_CHANGES\x10\x03\"f\n\x0fMonsterBehavior\x12\x12\n\nmonster_id\x18\x01 \x01(\x05\x12-\n\x06levels\x18\x02 \x03(\x0b\x32\x1d.dadguide_proto.LevelBehavior\x12\x10\n\x08\x61pproved\x18\x03 \x01(\x08\"M\n\rLevelBehavior\x12\r\n\x05level\x18\x01 \x01(\x05\x12-\n\x06groups\x18\x02 \x03(\x0b\x32\x1d.dadguide_proto.BehaviorGroup\"\xd9\x02\n\rBehaviorGroup\x12;\n\ngroup_type\x18\x01 \x01(\x0e\x32\'.dadguide_proto.BehaviorGroup.GroupType\x12,\n\tcondition\x18\x02 \x01(\x0b\x32\x19.dadguide_proto.Condition\x12.\n\x08\x63hildren\x18\x03 \x03(\x0b\x32\x1c.dadguide_proto.BehaviorItem\"\xac\x01\n\tGroupType\x12\x0f\n\x0bUNSPECIFIED\x10\x00\x12\x0b\n\x07PASSIVE\x10\x01\x12\x0b\n\x07PREEMPT\x10\x02\x12\x11\n\rDISPEL_PLAYER\x10\x03\x12\x12\n\x0eMONSTER_STATUS\x10\x04\x12\r\n\tREMAINING\x10\x05\x12\x0c\n\x08STANDARD\x10\x06\x12\t\n\x05\x44\x45\x41TH\x10\x07\x12\x0f\n\x0bUNKNOWN_USE\x10\x08\x12\x14\n\x10HIGHEST_PRIORITY\x10\t\"u\n\x0c\x42\x65haviorItem\x12.\n\x05group\x18\x02 \x01(\x0b\x32\x1d.dadguide_proto.BehaviorGroupH\x00\x12,\n\x08\x62\x65havior\x18\x03 \x01(\x0b\x32\x18.dadguide_proto.BehaviorH\x00\x42\x07\n\x05value\"c\n\x08\x42\x65havior\x12,\n\tcondition\x18\x01 \x01(\x0b\x32\x19.dadguide_proto.Condition\x12\x16\n\x0e\x65nemy_skill_id\x18\x02 \x01(\x05\x12\x11\n\tchild_ids\x18\x03 \x03(\x05\"\x80\x04\n\tCondition\x12\x14\n\x0chp_threshold\x18\x01 \x01(\x05\x12\x12\n\nuse_chance\x18\x02 \x01(\x05\x12\x15\n\rrepeats_every\x18\x03 \x01(\x05\x12\x17\n\x0fglobal_one_time\x18\x04 \x01(\x08\x12\x19\n\x11limited_execution\x18\r \x01(\x05\x12!\n\x19trigger_enemies_remaining\x18\x05 \x01(\x05\x12\x13\n\x0bif_defeated\x18\x06 \x01(\x08\x12\x1f\n\x17if_attributes_available\x18\x07 \x01(\x08\x12\x18\n\x10trigger_monsters\x18\x08 \x03(\x05\x12\x16\n\x0etrigger_combos\x18\t \x01(\x05\x12\x1a\n\x12if_nothing_matched\x18\n \x01(\x08\x12\x14\n\x0ctrigger_turn\x18\x0b \x01(\x05\x12\x18\n\x10trigger_turn_end\x18\x0c \x01(\x05\x12\x1c\n\x14\x61lways_trigger_above\x18\x0e \x01(\x05\x12\x14\n\x0c\x61lways_after\x18\x0f \x01(\x05\x12\x11\n\tskill_set\x18\x10 \x01(\x05\x12\x19\n\x11\x65rased_attributes\x18\x11 \x03(\x05\x12\x13\n\x0b\x64\x61mage_done\x18\x12 \x01(\x05\x12\x1b\n\x13\x61ttributes_attacked\x18\x13 \x03(\x05\x12\x13\n\x0bskills_used\x18\x14 \x01(\x05\x62\x06proto3' +) + +_MONSTERBEHAVIORWITHOVERRIDES_STATUS = _descriptor.EnumDescriptor( + name='Status', + full_name='dadguide_proto.MonsterBehaviorWithOverrides.Status', + filename=None, + file=DESCRIPTOR, + create_key=_descriptor._internal_create_key, + values=[ + _descriptor.EnumValueDescriptor( + name='NOT_APPROVED', index=0, number=0, + serialized_options=None, + type=None, + create_key=_descriptor._internal_create_key), + _descriptor.EnumValueDescriptor( + name='APPROVED_AS_IS', index=1, number=1, + serialized_options=None, + type=None, + create_key=_descriptor._internal_create_key), + _descriptor.EnumValueDescriptor( + name='NEEDS_REAPPROVAL', index=2, number=2, + serialized_options=None, + type=None, + create_key=_descriptor._internal_create_key), + _descriptor.EnumValueDescriptor( + name='APPROVED_WITH_CHANGES', index=3, number=3, + serialized_options=None, + type=None, + create_key=_descriptor._internal_create_key), + ], + containing_type=None, + serialized_options=None, + serialized_start=263, + serialized_end=358, +) +_sym_db.RegisterEnumDescriptor(_MONSTERBEHAVIORWITHOVERRIDES_STATUS) + +_BEHAVIORGROUP_GROUPTYPE = _descriptor.EnumDescriptor( + name='GroupType', + full_name='dadguide_proto.BehaviorGroup.GroupType', + filename=None, + file=DESCRIPTOR, + create_key=_descriptor._internal_create_key, + values=[ + _descriptor.EnumValueDescriptor( + name='UNSPECIFIED', index=0, number=0, + serialized_options=None, + type=None, + create_key=_descriptor._internal_create_key), + _descriptor.EnumValueDescriptor( + name='PASSIVE', index=1, number=1, + serialized_options=None, + type=None, + create_key=_descriptor._internal_create_key), + _descriptor.EnumValueDescriptor( + name='PREEMPT', index=2, number=2, + serialized_options=None, + type=None, + create_key=_descriptor._internal_create_key), + _descriptor.EnumValueDescriptor( + name='DISPEL_PLAYER', index=3, number=3, + serialized_options=None, + type=None, + create_key=_descriptor._internal_create_key), + _descriptor.EnumValueDescriptor( + name='MONSTER_STATUS', index=4, number=4, + serialized_options=None, + type=None, + create_key=_descriptor._internal_create_key), + _descriptor.EnumValueDescriptor( + name='REMAINING', index=5, number=5, + serialized_options=None, + type=None, + create_key=_descriptor._internal_create_key), + _descriptor.EnumValueDescriptor( + name='STANDARD', index=6, number=6, + serialized_options=None, + type=None, + create_key=_descriptor._internal_create_key), + _descriptor.EnumValueDescriptor( + name='DEATH', index=7, number=7, + serialized_options=None, + type=None, + create_key=_descriptor._internal_create_key), + _descriptor.EnumValueDescriptor( + name='UNKNOWN_USE', index=8, number=8, + serialized_options=None, + type=None, + create_key=_descriptor._internal_create_key), + _descriptor.EnumValueDescriptor( + name='HIGHEST_PRIORITY', index=9, number=9, + serialized_options=None, + type=None, + create_key=_descriptor._internal_create_key), + ], + containing_type=None, + serialized_options=None, + serialized_start=717, + serialized_end=889, +) +_sym_db.RegisterEnumDescriptor(_BEHAVIORGROUP_GROUPTYPE) + +_MONSTERBEHAVIORWITHOVERRIDES = _descriptor.Descriptor( + name='MonsterBehaviorWithOverrides', + full_name='dadguide_proto.MonsterBehaviorWithOverrides', + filename=None, + file=DESCRIPTOR, + containing_type=None, + create_key=_descriptor._internal_create_key, + fields=[ + _descriptor.FieldDescriptor( + name='monster_id', full_name='dadguide_proto.MonsterBehaviorWithOverrides.monster_id', index=0, + number=1, type=5, cpp_type=1, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='levels', full_name='dadguide_proto.MonsterBehaviorWithOverrides.levels', index=1, + number=2, type=11, cpp_type=10, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='level_overrides', full_name='dadguide_proto.MonsterBehaviorWithOverrides.level_overrides', index=2, + number=3, type=11, cpp_type=10, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='status', full_name='dadguide_proto.MonsterBehaviorWithOverrides.status', index=3, + number=4, type=14, cpp_type=8, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + _MONSTERBEHAVIORWITHOVERRIDES_STATUS, + ], + serialized_options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=39, + serialized_end=358, +) + +_MONSTERBEHAVIOR = _descriptor.Descriptor( + name='MonsterBehavior', + full_name='dadguide_proto.MonsterBehavior', + filename=None, + file=DESCRIPTOR, + containing_type=None, + create_key=_descriptor._internal_create_key, + fields=[ + _descriptor.FieldDescriptor( + name='monster_id', full_name='dadguide_proto.MonsterBehavior.monster_id', index=0, + number=1, type=5, cpp_type=1, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='levels', full_name='dadguide_proto.MonsterBehavior.levels', index=1, + number=2, type=11, cpp_type=10, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='approved', full_name='dadguide_proto.MonsterBehavior.approved', index=2, + number=3, type=8, cpp_type=7, label=1, + has_default_value=False, default_value=False, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=360, + serialized_end=462, +) + +_LEVELBEHAVIOR = _descriptor.Descriptor( + name='LevelBehavior', + full_name='dadguide_proto.LevelBehavior', + filename=None, + file=DESCRIPTOR, + containing_type=None, + create_key=_descriptor._internal_create_key, + fields=[ + _descriptor.FieldDescriptor( + name='level', full_name='dadguide_proto.LevelBehavior.level', index=0, + number=1, type=5, cpp_type=1, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='groups', full_name='dadguide_proto.LevelBehavior.groups', index=1, + number=2, type=11, cpp_type=10, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=464, + serialized_end=541, +) + +_BEHAVIORGROUP = _descriptor.Descriptor( + name='BehaviorGroup', + full_name='dadguide_proto.BehaviorGroup', + filename=None, + file=DESCRIPTOR, + containing_type=None, + create_key=_descriptor._internal_create_key, + fields=[ + _descriptor.FieldDescriptor( + name='group_type', full_name='dadguide_proto.BehaviorGroup.group_type', index=0, + number=1, type=14, cpp_type=8, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='condition', full_name='dadguide_proto.BehaviorGroup.condition', index=1, + number=2, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='children', full_name='dadguide_proto.BehaviorGroup.children', index=2, + number=3, type=11, cpp_type=10, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + _BEHAVIORGROUP_GROUPTYPE, + ], + serialized_options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=544, + serialized_end=889, +) + +_BEHAVIORITEM = _descriptor.Descriptor( + name='BehaviorItem', + full_name='dadguide_proto.BehaviorItem', + filename=None, + file=DESCRIPTOR, + containing_type=None, + create_key=_descriptor._internal_create_key, + fields=[ + _descriptor.FieldDescriptor( + name='group', full_name='dadguide_proto.BehaviorItem.group', index=0, + number=2, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='behavior', full_name='dadguide_proto.BehaviorItem.behavior', index=1, + number=3, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + _descriptor.OneofDescriptor( + name='value', full_name='dadguide_proto.BehaviorItem.value', + index=0, containing_type=None, + create_key=_descriptor._internal_create_key, + fields=[]), + ], + serialized_start=891, + serialized_end=1008, +) + +_BEHAVIOR = _descriptor.Descriptor( + name='Behavior', + full_name='dadguide_proto.Behavior', + filename=None, + file=DESCRIPTOR, + containing_type=None, + create_key=_descriptor._internal_create_key, + fields=[ + _descriptor.FieldDescriptor( + name='condition', full_name='dadguide_proto.Behavior.condition', index=0, + number=1, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='enemy_skill_id', full_name='dadguide_proto.Behavior.enemy_skill_id', index=1, + number=2, type=5, cpp_type=1, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='child_ids', full_name='dadguide_proto.Behavior.child_ids', index=2, + number=3, type=5, cpp_type=1, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=1010, + serialized_end=1109, +) + +_CONDITION = _descriptor.Descriptor( + name='Condition', + full_name='dadguide_proto.Condition', + filename=None, + file=DESCRIPTOR, + containing_type=None, + create_key=_descriptor._internal_create_key, + fields=[ + _descriptor.FieldDescriptor( + name='hp_threshold', full_name='dadguide_proto.Condition.hp_threshold', index=0, + number=1, type=5, cpp_type=1, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='use_chance', full_name='dadguide_proto.Condition.use_chance', index=1, + number=2, type=5, cpp_type=1, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='repeats_every', full_name='dadguide_proto.Condition.repeats_every', index=2, + number=3, type=5, cpp_type=1, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='global_one_time', full_name='dadguide_proto.Condition.global_one_time', index=3, + number=4, type=8, cpp_type=7, label=1, + has_default_value=False, default_value=False, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='limited_execution', full_name='dadguide_proto.Condition.limited_execution', index=4, + number=13, type=5, cpp_type=1, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='trigger_enemies_remaining', full_name='dadguide_proto.Condition.trigger_enemies_remaining', index=5, + number=5, type=5, cpp_type=1, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='if_defeated', full_name='dadguide_proto.Condition.if_defeated', index=6, + number=6, type=8, cpp_type=7, label=1, + has_default_value=False, default_value=False, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='if_attributes_available', full_name='dadguide_proto.Condition.if_attributes_available', index=7, + number=7, type=8, cpp_type=7, label=1, + has_default_value=False, default_value=False, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='trigger_monsters', full_name='dadguide_proto.Condition.trigger_monsters', index=8, + number=8, type=5, cpp_type=1, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='trigger_combos', full_name='dadguide_proto.Condition.trigger_combos', index=9, + number=9, type=5, cpp_type=1, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='if_nothing_matched', full_name='dadguide_proto.Condition.if_nothing_matched', index=10, + number=10, type=8, cpp_type=7, label=1, + has_default_value=False, default_value=False, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='trigger_turn', full_name='dadguide_proto.Condition.trigger_turn', index=11, + number=11, type=5, cpp_type=1, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='trigger_turn_end', full_name='dadguide_proto.Condition.trigger_turn_end', index=12, + number=12, type=5, cpp_type=1, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='always_trigger_above', full_name='dadguide_proto.Condition.always_trigger_above', index=13, + number=14, type=5, cpp_type=1, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='always_after', full_name='dadguide_proto.Condition.always_after', index=14, + number=15, type=5, cpp_type=1, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='skill_set', full_name='dadguide_proto.Condition.skill_set', index=15, + number=16, type=5, cpp_type=1, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='erased_attributes', full_name='dadguide_proto.Condition.erased_attributes', index=16, + number=17, type=5, cpp_type=1, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='damage_done', full_name='dadguide_proto.Condition.damage_done', index=17, + number=18, type=5, cpp_type=1, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='attributes_attacked', full_name='dadguide_proto.Condition.attributes_attacked', index=18, + number=19, type=5, cpp_type=1, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='skills_used', full_name='dadguide_proto.Condition.skills_used', index=19, + number=20, type=5, cpp_type=1, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=1112, + serialized_end=1624, +) + +_MONSTERBEHAVIORWITHOVERRIDES.fields_by_name['levels'].message_type = _LEVELBEHAVIOR +_MONSTERBEHAVIORWITHOVERRIDES.fields_by_name['level_overrides'].message_type = _LEVELBEHAVIOR +_MONSTERBEHAVIORWITHOVERRIDES.fields_by_name['status'].enum_type = _MONSTERBEHAVIORWITHOVERRIDES_STATUS +_MONSTERBEHAVIORWITHOVERRIDES_STATUS.containing_type = _MONSTERBEHAVIORWITHOVERRIDES +_MONSTERBEHAVIOR.fields_by_name['levels'].message_type = _LEVELBEHAVIOR +_LEVELBEHAVIOR.fields_by_name['groups'].message_type = _BEHAVIORGROUP +_BEHAVIORGROUP.fields_by_name['group_type'].enum_type = _BEHAVIORGROUP_GROUPTYPE +_BEHAVIORGROUP.fields_by_name['condition'].message_type = _CONDITION +_BEHAVIORGROUP.fields_by_name['children'].message_type = _BEHAVIORITEM +_BEHAVIORGROUP_GROUPTYPE.containing_type = _BEHAVIORGROUP +_BEHAVIORITEM.fields_by_name['group'].message_type = _BEHAVIORGROUP +_BEHAVIORITEM.fields_by_name['behavior'].message_type = _BEHAVIOR +_BEHAVIORITEM.oneofs_by_name['value'].fields.append( + _BEHAVIORITEM.fields_by_name['group']) +_BEHAVIORITEM.fields_by_name['group'].containing_oneof = _BEHAVIORITEM.oneofs_by_name['value'] +_BEHAVIORITEM.oneofs_by_name['value'].fields.append( + _BEHAVIORITEM.fields_by_name['behavior']) +_BEHAVIORITEM.fields_by_name['behavior'].containing_oneof = _BEHAVIORITEM.oneofs_by_name['value'] +_BEHAVIOR.fields_by_name['condition'].message_type = _CONDITION +DESCRIPTOR.message_types_by_name['MonsterBehaviorWithOverrides'] = _MONSTERBEHAVIORWITHOVERRIDES +DESCRIPTOR.message_types_by_name['MonsterBehavior'] = _MONSTERBEHAVIOR +DESCRIPTOR.message_types_by_name['LevelBehavior'] = _LEVELBEHAVIOR +DESCRIPTOR.message_types_by_name['BehaviorGroup'] = _BEHAVIORGROUP +DESCRIPTOR.message_types_by_name['BehaviorItem'] = _BEHAVIORITEM +DESCRIPTOR.message_types_by_name['Behavior'] = _BEHAVIOR +DESCRIPTOR.message_types_by_name['Condition'] = _CONDITION +_sym_db.RegisterFileDescriptor(DESCRIPTOR) + +MonsterBehaviorWithOverrides = _reflection.GeneratedProtocolMessageType('MonsterBehaviorWithOverrides', + (_message.Message,), { + 'DESCRIPTOR': _MONSTERBEHAVIORWITHOVERRIDES, + '__module__': 'enemy_skills_pb2' + # @@protoc_insertion_point(class_scope:dadguide_proto.MonsterBehaviorWithOverrides) + }) +_sym_db.RegisterMessage(MonsterBehaviorWithOverrides) + +MonsterBehavior = _reflection.GeneratedProtocolMessageType('MonsterBehavior', (_message.Message,), { + 'DESCRIPTOR': _MONSTERBEHAVIOR, + '__module__': 'enemy_skills_pb2' + # @@protoc_insertion_point(class_scope:dadguide_proto.MonsterBehavior) +}) +_sym_db.RegisterMessage(MonsterBehavior) + +LevelBehavior = _reflection.GeneratedProtocolMessageType('LevelBehavior', (_message.Message,), { + 'DESCRIPTOR': _LEVELBEHAVIOR, + '__module__': 'enemy_skills_pb2' + # @@protoc_insertion_point(class_scope:dadguide_proto.LevelBehavior) +}) +_sym_db.RegisterMessage(LevelBehavior) + +BehaviorGroup = _reflection.GeneratedProtocolMessageType('BehaviorGroup', (_message.Message,), { + 'DESCRIPTOR': _BEHAVIORGROUP, + '__module__': 'enemy_skills_pb2' + # @@protoc_insertion_point(class_scope:dadguide_proto.BehaviorGroup) +}) +_sym_db.RegisterMessage(BehaviorGroup) + +BehaviorItem = _reflection.GeneratedProtocolMessageType('BehaviorItem', (_message.Message,), { + 'DESCRIPTOR': _BEHAVIORITEM, + '__module__': 'enemy_skills_pb2' + # @@protoc_insertion_point(class_scope:dadguide_proto.BehaviorItem) +}) +_sym_db.RegisterMessage(BehaviorItem) + +Behavior = _reflection.GeneratedProtocolMessageType('Behavior', (_message.Message,), { + 'DESCRIPTOR': _BEHAVIOR, + '__module__': 'enemy_skills_pb2' + # @@protoc_insertion_point(class_scope:dadguide_proto.Behavior) +}) +_sym_db.RegisterMessage(Behavior) + +Condition = _reflection.GeneratedProtocolMessageType('Condition', (_message.Message,), { + 'DESCRIPTOR': _CONDITION, + '__module__': 'enemy_skills_pb2' + # @@protoc_insertion_point(class_scope:dadguide_proto.Condition) +}) +_sym_db.RegisterMessage(Condition) + +# @@protoc_insertion_point(module_scope) diff --git a/dungeoncog/grouped_skillls.py b/dungeoncog/grouped_skillls.py new file mode 100644 index 00000000..be3e6459 --- /dev/null +++ b/dungeoncog/grouped_skillls.py @@ -0,0 +1,41 @@ +class GroupedSkills(object): + """This is a container class that holds the top level condition and its processed children""" + + def __init__(self, condition: str, skill_type, parent=None): + self.nested_groups = [] + self.skills = [] + self.condition = condition + self.type = skill_type + self.parent = parent + + def add_group(self, group): + self.nested_groups.append(group) + + def add_skill(self, skill): + self.skills.append(skill) + + def give_string2(self, lines, level=0, verbose: bool = False): + """ + When called it stores the necessary text of it's child skills in lines. + @param lines: a list to store lines of text in the following format [number of indents needed, actual line of text] + @param level: how many indents do we need (essentially is this a nested group or not) + @param verbose: display skill effect text or not + """ + condition = self.condition + if condition is not None: + lines.append([level, ["**Condition: {}:**".format(condition)]]) + level += 1 + for g in self.nested_groups: + g.give_string2(lines, level, verbose) + for s in self.skills: + skill_string_components = s.give_string(verbose=verbose) + skill_string_components[0] = "".join(skill_string_components[0]) + skill_string_components[0] = "**{}**".format(skill_string_components[0]) + skill_string_components = [x for x in skill_string_components if x is not None] + lines.append([level, skill_string_components]) + + async def collect_skills(self): + skill_copy = self.skills.copy() + for g in self.nested_groups: + skill_copy.extend(await g.collect_skills()) + return skill_copy diff --git a/dungeoncog/info.json b/dungeoncog/info.json new file mode 100644 index 00000000..720cb657 --- /dev/null +++ b/dungeoncog/info.json @@ -0,0 +1,31 @@ +{ + "author": [ + "loopdeer", + "The Tsubaki Bot Team" + ], + "description": "Allows users to get data from dungeons.", + "end_user_data_statement": "No personal data is stored.", + "install_msg": "Use [p]dgid to query", + "short": "Serves dungeon data", + "min_bot_version": "3.4.0", + "max_bot_version": "0.0.0", + "min_python_version": [ + 3, + 8, + 1 + ], + "hidden": false, + "disabled": false, + "required_cogs": { + "dbcog": "https://github.com/TsubakiBotPad/pad-cogs", + "menulistener": "https://github.com/TsubakiBotPad/core-cogs" + }, + "requirements": [ + "tsutils>=3.4.0", + "discord-menu>=0.16.0", + "protobuf" + ], + "tags": [ + "PAD" + ] +} diff --git a/dungeoncog/menu/__init__.py b/dungeoncog/menu/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dungeoncog/menu/dungeon.py b/dungeoncog/menu/dungeon.py new file mode 100644 index 00000000..326092ce --- /dev/null +++ b/dungeoncog/menu/dungeon.py @@ -0,0 +1,121 @@ +from typing import Optional + +from discord import Message +from discordmenu.embed.emoji import DELETE_MESSAGE_EMOJI +from discordmenu.embed.transitions import EmbedTransition, EmbedMenuDefaultTransitions +from discordmenu.embed.wrapper import EmbedWrapper +from discordmenu.embed.menu import EmbedMenu +from tsutils.menu.components.panes import MenuPanes, emoji_buttons + +from dungeoncog.view.dungeon import DungeonViewState, DungeonView + + +class DungeonNames: + home = 'home' + + +class DungeonMenu: + MENU_TYPE = 'DungeonMenu' + message = None + + @staticmethod + def menu(): + embed = EmbedMenu(DungeonMenuPanes.transitions(), DungeonMenu.message_control, + EmbedMenuDefaultTransitions( + delete_message=EmbedTransition(DELETE_MESSAGE_EMOJI, DungeonMenu.respond_with_delete))) + return embed + + @staticmethod + async def respond_with_message(message: Optional[Message], ims, **data): + dbcog = data.get('dbcog') + view_state = await DungeonViewState.deserialize(dbcog, ims) + control = DungeonMenu.message_control(view_state) + return control + + @staticmethod + async def respond_with_verbose(message: Optional[Message], ims, **data): + dbcog = data.get('dbcog') + view_state = await DungeonViewState.deserialize(dbcog, ims, verbose_toggle=True) + control = DungeonMenu.message_control(view_state) + return control + + @staticmethod + async def respond_with_delete(message: Optional[Message], ims, **data): + return await message.delete() + + @staticmethod + async def respond_with_previous_monster(message: Optional[Message], ims, **data): + dbcog = data['dbcog'] + view_state = await DungeonViewState.deserialize(dbcog, ims, 0, -1) + control = DungeonMenu.message_control(view_state) + return control + + @staticmethod + async def respond_with_next_monster(message: Optional[Message], ims, **data): + dbcog = data['dbcog'] + view_state = await DungeonViewState.deserialize(dbcog, ims, 0, 1) + control = DungeonMenu.message_control(view_state) + return control + + @staticmethod + async def respond_with_previous_floor(message: Optional[Message], ims, **data): + dbcog = data['dbcog'] + view_state = await DungeonViewState.deserialize(dbcog, ims, -1, 0, reset_spawn=True) + control = DungeonMenu.message_control(view_state) + return control + + @staticmethod + async def respond_with_next_floor(message: Optional[Message], ims, **data): + dbcog = data['dbcog'] + view_state = await DungeonViewState.deserialize(dbcog, ims, 1, 0, reset_spawn=True) + control = DungeonMenu.message_control(view_state) + return control + + @staticmethod + async def respond_with_next_page(message: Optional[Message], ims, **data): + dbcog = data['dbcog'] + page = ims.get('page') + if page == 0: + page = 1 + else: + page = 0 + view_state = await DungeonViewState.deserialize(dbcog, ims, page=page) + control = DungeonMenu.message_control(view_state) + return control + + @staticmethod + def message_control(state: DungeonViewState): + if state is None: + return None + return EmbedWrapper( + DungeonView.embed(state), + DungeonMenuPanes.emoji_names() + ) + + +class DungeonEmoji: + home = emoji_buttons['home'] + verbose = '\N{SCROLL}' + previous_monster = '\N{BLACK LEFT-POINTING DOUBLE TRIANGLE}' + next_monster = '\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE}' + previous_floor = ('minus', '\N{DOWNWARDS BLACK ARROW}') + next_floor = ('plus', '\N{UPWARDS BLACK ARROW}') + next_page = '\N{OPEN BOOK}' + + +class DungeonMenuPanes(MenuPanes): + INITIAL_EMOJI = DungeonEmoji.home + + DATA = { + DungeonEmoji.home: (DungeonMenu.respond_with_message, DungeonView.VIEW_TYPE), + DungeonEmoji.verbose: (DungeonMenu.respond_with_verbose, DungeonView.VIEW_TYPE), + DungeonEmoji.previous_monster: (DungeonMenu.respond_with_previous_monster, DungeonView.VIEW_TYPE), + DungeonEmoji.next_monster: (DungeonMenu.respond_with_next_monster, DungeonView.VIEW_TYPE), + DungeonEmoji.previous_floor: (DungeonMenu.respond_with_previous_floor, DungeonView.VIEW_TYPE), + DungeonEmoji.next_floor: (DungeonMenu.respond_with_next_floor, DungeonView.VIEW_TYPE), + DungeonEmoji.next_page: (DungeonMenu.respond_with_next_page, DungeonView.VIEW_TYPE) + } + + HIDDEN_EMOJIS = [ + DungeonEmoji.home + ] diff --git a/dungeoncog/menu/menu_map.py b/dungeoncog/menu/menu_map.py new file mode 100644 index 00000000..c40309a5 --- /dev/null +++ b/dungeoncog/menu/menu_map.py @@ -0,0 +1,7 @@ +from dungeoncog.menu.dungeon import DungeonMenu, DungeonMenuPanes +from dungeoncog.menu.simple import SimpleMenu, SimpleMenuPanes + +dungeon_menu_map = { + SimpleMenu.MENU_TYPE: (SimpleMenu, SimpleMenuPanes), + DungeonMenu.MENU_TYPE: (DungeonMenu, DungeonMenuPanes), +} diff --git a/dungeoncog/menu/simple.py b/dungeoncog/menu/simple.py new file mode 100644 index 00000000..12799347 --- /dev/null +++ b/dungeoncog/menu/simple.py @@ -0,0 +1,71 @@ +from typing import Optional + +from discord import Message +from discordmenu.embed.emoji import DELETE_MESSAGE_EMOJI +from discordmenu.embed.transitions import EmbedMenuDefaultTransitions, EmbedTransition +from discordmenu.embed.wrapper import EmbedWrapper +from discordmenu.embed.menu import EmbedMenu +from tsutils.menu.components.panes import MenuPanes, emoji_buttons + +from dungeoncog.view.simple import SimpleViewState, SimpleView + + +class SimpleNames: + home = 'home' + + +class SimpleMenu: + MENU_TYPE = 'SimpleMenu' + message = None + + @staticmethod + def menu(): + embed = EmbedMenu(SimpleMenuPanes.transitions(), SimpleMenu.message_control, + EmbedMenuDefaultTransitions( + delete_message=EmbedTransition(DELETE_MESSAGE_EMOJI, SimpleMenu.respond_with_delete))) + return embed + + @staticmethod + async def respond_with_message(message: Optional[Message], ims, **data): + dbcog = data.get('dbcog') + view_state = await SimpleViewState.deserialize(dbcog, ims, 0) + control = SimpleMenu.message_control(view_state) + return control + + @staticmethod + async def respond_with_delete(message: Optional[Message], ims, **data): + return await message.delete() + + @staticmethod + async def respond_with_right(message: Optional[Message], ims, **data): + dbcog = data.get('dbcog') + view_state = await SimpleViewState.deserialize(dbcog, ims, 1) + control = SimpleMenu.message_control(view_state) + return control + + @staticmethod + def message_control(state: SimpleViewState): + if state is None: + return None + reaction_list = state.reaction_list + return EmbedWrapper( + SimpleView.embed(state), + reaction_list + ) + + +class SimpleEmoji: + home = emoji_buttons['home'] + right = '\N{BLACK RIGHT-POINTING TRIANGLE}' + + +class SimpleMenuPanes(MenuPanes): + INITIAL_EMOJI = SimpleEmoji.home + DATA = { + SimpleEmoji.home: (SimpleMenu.respond_with_message, SimpleNames.home), + SimpleEmoji.right: (SimpleMenu.respond_with_right, SimpleView.VIEW_TYPE) + + } + HIDDEN_EMOJIS = [ + SimpleNames.home, + ] diff --git a/dungeoncog/models/__init__.py b/dungeoncog/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dungeoncog/models/enemy_skill.py b/dungeoncog/models/enemy_skill.py new file mode 100644 index 00000000..48ee1945 --- /dev/null +++ b/dungeoncog/models/enemy_skill.py @@ -0,0 +1,31 @@ +from typing import List + + +class EnemySkill(object): + def __init__(self, raw: List[str]): + self.enemy_skill_id = int(raw[0]) + self.name = raw[1].replace('\n', ' ') + self.type = int(raw[2]) + self.flags = int(raw[3], 16) # 16bitmap for params + self.params: List = [None] * 16 + offset = 0 + p_idx = 4 + while offset < self.flags.bit_length(): + if (self.flags >> offset) & 1 != 0: + p_value = raw[p_idx] + self.params[offset] = int(p_value) if p_value.lstrip('-').isdigit() else p_value + p_idx += 1 + offset += 1 + + def process(self): + return "Unknown: [{},{},{},{}]".format(self.enemy_skill_id, self.name, self.type, self.params) + + +class ESNone(EnemySkill): + def __init__(self, esid=None): + super().__init__([esid, "ESNone", -1, 0]) + self.enemy_skill_id = esid + self.name = None + self.type = None + self.flags = None + self.params = None diff --git a/dungeoncog/processors.py b/dungeoncog/processors.py new file mode 100644 index 00000000..c471731a --- /dev/null +++ b/dungeoncog/processors.py @@ -0,0 +1,260 @@ +import re +from typing import Optional, TYPE_CHECKING + +from discordmenu.emoji.emoji_cache import emoji_cache +from tsutils.enums import Server + +from dungeoncog.dungeon_monster import DungeonMonster +from dungeoncog.enemy_skill import ProcessedSkill +from dungeoncog.enemy_skills_pb2 import Behavior, BehaviorGroup, Condition, MonsterBehavior +from dungeoncog.grouped_skillls import GroupedSkills + +if TYPE_CHECKING: + from dbcog.models.enemy_skill_model import EnemySkillModel + from dbcog.database_context import DbContext + from dbcog.models.encounter_model import EncounterModel + + +DEFAULT_SERVER = Server.COMBINED + +GROUP_TYPES = { + 0: "Unspecified", + 1: "Passive", + 2: "Preemptive", + 3: "Dispel Player", + 4: "Monster Status", + 5: "Remaining", + 6: "Standard", + 7: "Death", + 8: "Unknown", + 9: "Highest Priority" +} + + +def format_condition(cond: Condition) -> Optional[str]: + parts = [] + if cond.skill_set: + parts.append(f"SkillSet {cond.skill_set}") + if cond.use_chance not in [0, 100]: + parts.append(f"{cond.use_chance}% chance") + if cond.global_one_time: + parts.append(f"one time only") + if cond.limited_execution: + parts.append(f"at most {cond.limited_execution} times") + if cond.trigger_enemies_remaining: + parts.append(f"when {cond.trigger_enemies_remaining} enemies remain") + if cond.if_defeated: + parts.append(f"when defeated") + if cond.if_attributes_available: + parts.append(f"when required attributes on board") + if cond.trigger_monsters: + parts.append(f"when {', '.join(map(str, cond.trigger_monsters))} on team") + if cond.trigger_combos: + parts.append(f"when {cond.trigger_combos} combos last turn") + if cond.if_nothing_matched: + parts.append(f"if no other skills matched") + if cond.repeats_every: + if cond.trigger_turn: + if cond.trigger_turn_end: + parts.append(f"turns {cond.trigger_turn}-{cond.trigger_turn_end} of every {cond.repeats_every} turns") + else: + parts.append(f"turn {cond.trigger_turn} of every {cond.repeats_every} turns") + else: + parts.append(f"repeats every {cond.repeats_every} turns") + elif cond.trigger_turn_end: + turn_text = f"turns {cond.trigger_turn}-{cond.trigger_turn_end}" + parts.append(_cond_hp_timed_text(cond.always_trigger_above, turn_text)) + elif cond.trigger_turn: + turn_text = f"turn {cond.trigger_turn}" + parts.append(_cond_hp_timed_text(cond.always_trigger_above, turn_text)) + + if cond.hp_threshold == 101: + parts.append(f"when HP is full") + elif cond.hp_threshold not in (100, 0): + parts.append(f"HP <= {cond.hp_threshold}") + + return ', '.join(parts) or None + + +# From pad-data-pipeline +def _cond_hp_timed_text(always_trigger_above: int, turn_text: str) -> str: + text = turn_text + if always_trigger_above == 1: + text = f"always {turn_text}" + elif always_trigger_above: + text = f"{turn_text} while HP > {always_trigger_above}" + return text + + +def format_overview(query): + output = f"Dungeon Name: {query[0]['dungeon_name_en']}" + for q in query: + output += f"\nEncounter ID: {q['encounter_id']} Stage: {q['stage']} Monster Name: {q['name_en']}" + return output + + +def behavior_for_level(behavior: MonsterBehavior, num: int): + lev = None + for level in behavior.levels: + if level.level == num: + lev = level + if lev is None: + return behavior.levels[len(behavior.levels) - 1] + else: + return lev + + +def process_enemy_skill(encounter: "EncounterModel", skill: "EnemySkillModel", emoji_map): + non_attack_effects = [] + for e in re.split(r'(?<=\)), ', skill.desc_en_emoji): + if "Attack:" not in e: + non_attack_effects.append(e.format_map(emoji_map)) + + # Damage + atk = encounter.atk + if skill.min_hits != 0: + emoji = emoji_map['attack'] + if skill.min_hits > 1: + emoji = emoji_map['multi_attack'] + damage_per_hit = int(atk * (skill.atk_mult / 100)) + min_damage = skill.min_hits * damage_per_hit + max_damage = skill.max_hits * damage_per_hit + if min_damage != max_damage: + non_attack_effects.append(f"({emoji}:{min_damage:,}~{max_damage:,})") + else: + non_attack_effects.append(f"({emoji}:{min_damage:,})") + + return non_attack_effects + + +def process_behavior(behavior: Behavior, database, encounter: "EncounterModel", emoji_map, + parent: GroupedSkills = None, server: Server = DEFAULT_SERVER): + skill = database.dungeon.get_enemy_skill(behavior.enemy_skill_id, server=server) + if skill is None: + return "Unknown" + skill_name = skill.name_en + skill_effect = skill.desc_en + skill_processed_texts = process_enemy_skill(encounter, skill, emoji_map) + + condition = format_condition(behavior.condition) + processed_skill: ProcessedSkill = ProcessedSkill(skill_name, skill_effect, skill_processed_texts, condition, + parent) + return processed_skill + + +def process_behavior_group(group: BehaviorGroup, database, encounter: "EncounterModel", emoji_map, + parent: GroupedSkills = None): + condition = format_condition(group.condition) + processed_group: GroupedSkills = GroupedSkills(condition, GROUP_TYPES[group.group_type], parent) + for child in group.children: + if child.HasField('group'): + processed_group.add_group( + process_behavior_group(child.group, database, encounter, emoji_map, processed_group)) + elif child.HasField('behavior'): + processed_group.add_skill(process_behavior(child.behavior, database, encounter, emoji_map, processed_group)) + return processed_group + + +class SafeDgEmojiDict(dict): + def __missing__(self, key): + return '<' + key + '>' + + +def process_monster(behavior: MonsterBehavior, encounter: "EncounterModel", database: "DbContext") -> DungeonMonster: + emoji_map = SafeDgEmojiDict( + resolve_status=emoji_cache.get_by_name('resolve'), + fire_orb=emoji_cache.get_by_name('orb_fire'), + recover_health=emoji_cache.get_by_name('misc_autoheal'), + roulette=emoji_cache.get_by_name('spinner'), + unknown_type='Unknown', + defense_status=emoji_cache.get_by_name('defense'), + combo_absorb_status=emoji_cache.get_by_name('combo'), + absorb_status=emoji_cache.get_by_name('attribute_absorb'), + damage_absorb_status=emoji_cache.get_by_name('damage_absorb'), + damage_void_status=emoji_cache.get_by_name('vdp'), + status_shield_status=emoji_cache.get_by_name('status'), + movetime_buff_status=emoji_cache.get_by_name('time_buff'), + movetime_debuff_status=emoji_cache.get_by_name('time_debuff'), + dispel_status='Dispel', + leader_swap_status=emoji_cache.get_by_name('swap'), + skill_delay=emoji_cache.get_by_name('misc_sb'), + lock_orbs=emoji_cache.get_by_name('lock'), + tape_status=emoji_cache.get_by_name('res_seal'), + fixed_start=emoji_cache.get_by_name('orb_start'), + cloud_status=emoji_cache.get_by_name('res_cloud'), + gravity=emoji_cache.get_by_name('gravity'), + invincible_status=emoji_cache.get_by_name('invincible'), + invincible_off_status=emoji_cache.get_by_name('invincible'), # TODO + force_target_status=emoji_cache.get_by_name('force_target'), + leader_alter_status=emoji_cache.get_by_name('transform'), + board_size_status=emoji_cache.get_by_name('board_size'), + super_resolve_status=emoji_cache.get_by_name('super_resolve'), + turn_change=emoji_cache.get_by_name('turn_change'), + enrage_status=emoji_cache.get_by_name('enrage'), + active_skill_bind=emoji_cache.get_by_name('skill_bound'), + do_nothing='\N{SLEEPING SYMBOL}', + awoken_skill_bind=emoji_cache.get_by_name('awoken_skill_bind'), + no_skyfall_status=emoji_cache.get_by_name('no_skyfall'), + bind=emoji_cache.get_by_name('res_bind'), + skyfall_status='\N{CLOUD WITH RAIN}', + blind=emoji_cache.get_by_name('black_circle_real'), + super_blind=emoji_cache.get_by_name('blind_orb'), + to='\N{BLACK RIGHTWARDS ARROW}️', + attack=emoji_cache.get_by_name('single_hit'), + multi_attack=emoji_cache.get_by_name('multi_hit'), + target_self='Self', + health=emoji_cache.get_by_name('health'), + combo_orb=emoji_cache.get_by_name('orb_combo'), + locked_bomb_orb=emoji_cache.get_by_name('locked_bomb_orb'), + random_attribute='[Random Att]', + water_orb=emoji_cache.get_by_name('orb_water'), + wood_orb=emoji_cache.get_by_name('orb_wood'), + light_orb=emoji_cache.get_by_name('orb_light'), + dark_orb=emoji_cache.get_by_name('orb_dark'), + healer_orb=emoji_cache.get_by_name('heal_orb'), + jammer_orb=emoji_cache.get_by_name('jammer_orb'), + poison_orb=emoji_cache.get_by_name('poison_orb'), + bomb_orb=emoji_cache.get_by_name('bomb_orb'), + mortal_poison_orb=emoji_cache.get_by_name('mortal_poison_orb'), + evo_material_type=emoji_cache.get_by_name('killer_evomat'), + balanced_type=emoji_cache.get_by_name('killer_balance'), + physical_type=emoji_cache.get_by_name('killer_physical'), + healer_type=emoji_cache.get_by_name('killer_healer'), + dragon_type=emoji_cache.get_by_name('killer_dragon'), + god_type=emoji_cache.get_by_name('killer_god'), + attacker_type=emoji_cache.get_by_name('killer_attacker'), + devil_type=emoji_cache.get_by_name('killer_devil'), + machine_type=emoji_cache.get_by_name('killer_machine'), + awakening_material_type=emoji_cache.get_by_name('killer_awoken'), + enhance_material_type=emoji_cache.get_by_name('killer_enhancemat'), + redeemable_material_type='\U0001fa99', # Coin Emoji + no_match_fire=emoji_cache.get_by_name('no_match_fire'), + no_match_wood=emoji_cache.get_by_name('no_match_wood'), + no_match_water=emoji_cache.get_by_name('no_match_water'), + no_match_dark=emoji_cache.get_by_name('no_match_dark'), + no_match_light=emoji_cache.get_by_name('no_match_light'), + no_match_heart=emoji_cache.get_by_name('no_match_heart'), + no_match_jammer=emoji_cache.get_by_name('no_match_jammer'), + no_match_poison=emoji_cache.get_by_name('no_match_poison'), + no_match_heal=emoji_cache.get_by_name('no_match_heal'), + recover_debuff_status=emoji_cache.get_by_name('recover_debuff_status'), + attack_debuff_status='\N{CROSSED SWORDS}\N{DOWNWARDS BLACK ARROW}️' + ) + monster_model = database.graph.get_monster(encounter.monster_id) + monster = DungeonMonster( + hp=encounter.hp, + atk=encounter.atk, + defense=encounter.defense, + turns=encounter.turns, + level=encounter.level, + monster=monster_model + ) + if behavior is None: + return monster + level_behavior = behavior_for_level(behavior, encounter.level) + if level_behavior is None: + return monster + for group in level_behavior.groups: + monster.add_group(process_behavior_group(group, database, encounter, emoji_map)) + + return monster diff --git a/dungeoncog/view/__init__.py b/dungeoncog/view/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dungeoncog/view/dungeon.py b/dungeoncog/view/dungeon.py new file mode 100644 index 00000000..cfdb10e1 --- /dev/null +++ b/dungeoncog/view/dungeon.py @@ -0,0 +1,280 @@ +import random +from typing import TYPE_CHECKING, List + +import discord +from discord import Embed +from discordmenu.embed.base import Box +from discordmenu.embed.components import EmbedMain, EmbedField +from discordmenu.embed.view import EmbedView +from discordmenu.embed.view_state import ViewState +from tsutils.enums import Server +from tsutils.menu.components.footers import embed_footer_with_state +from tsutils.tsubaki.monster_header import MonsterHeader + +from dungeoncog.dungeon_monster import DungeonMonster +from dungeoncog.enemy_skills_pb2 import MonsterBehavior +from dungeoncog.processors import process_monster + +if TYPE_CHECKING: + from dbcog.models.encounter_model import EncounterModel + + +class DungeonViewState(ViewState): + def __init__(self, original_author_id: int, menu_type: str, raw_query: str, + encounter: "EncounterModel", sub_dungeon_id: int, num_floors: int, floor: int, num_spawns: int, + floor_index: int, technical: int, database, + page: int = 0, verbose: bool = False): + super().__init__(original_author_id, menu_type, raw_query) + self.encounter = encounter + self.sub_dungeon_id = sub_dungeon_id + self.num_floors = num_floors + self.floor = floor + self.floor_index = floor_index + self.technical = technical + self.database = database + self.num_spawns = num_spawns + self.page = page + self.verbose = verbose + + def serialize(self): + ret = super().serialize() + ret.update({ + 'sub_dungeon_id': self.sub_dungeon_id, + 'num_floors': self.num_floors, + 'floor': self.floor, + 'floor_index': self.floor_index, + 'technical': self.technical, + 'pane_type': DungeonView.VIEW_TYPE, + 'verbose': self.verbose, + 'page': self.page + }) + return ret + + @classmethod + async def deserialize(cls, dbcog, ims: dict, inc_floor: int = 0, inc_index: int = 0, + verbose_toggle: bool = False, page: int = 0, reset_spawn: bool = False): + original_author_id = ims['original_author_id'] + menu_type = ims['menu_type'] + raw_query = ims.get('raw_query') + sub_dungeon_id = ims.get('sub_dungeon_id') + num_floors = ims.get('num_floors') + floor = ims.get('floor') + inc_floor + floor_index = ims.get('floor_index') + inc_index + technical = ims.get('technical') + verbose = ims.get('verbose') + # check if we are on final floor/final monster of the floor + if floor > num_floors: + floor = 1 + + if floor < 1: + floor = num_floors + + # toggle verbose + if verbose_toggle: + verbose = not verbose + + # get encounter models for the floor + floor_models = dbcog.database.dungeon.get_floor_from_sub_dungeon(sub_dungeon_id, floor, server=Server.COMBINED) + + # check if we are on final monster of the floor + if floor_index >= len(floor_models): + floor_index = 0 + + if floor_index < 0: + floor_index = len(floor_models) + floor_index + + # check if we reset the floor_index + if reset_spawn: + floor_index = 0 + + encounter_model = floor_models[floor_index] + + return cls(original_author_id, menu_type, raw_query, encounter_model, sub_dungeon_id, + num_floors, floor, len(floor_models), floor_index, + technical, dbcog.database, verbose=verbose, page=page) + + +class DungeonView: + VIEW_TYPE = 'DungeonText' + + egg_text = [ + "But nobody came...", + "Come again later", + "Tsubaki loves you", + "Remember 100 turn Hera stalling?", + "Odin x Mermaid are the OG heroes", + "Never forget 10 minute stamina", + "Another 3*...", + "Nice!", + "Maybe one day DBZ will come", + "Special thanks to the PADX team and tactical_retreat!" + ] + + @staticmethod + def indent(level): + """ + Helper function that indents text for the embed + """ + ret = "" + for lv in range(level): + if lv == 0: + ret += "> " + else: + ret += "\u200b \u200b \u200b \u200b \u200b " + return ret + + @staticmethod + def embed_helper(level, names, values, line): + """ + Adds a line in such a way to maximize discord embed limits. + We first try to add it to the most recent name part of the name/value pair. + If that fails we try to add it to the corresponding value. If that fails, + we create another name/value pair. + @param level: how many indents the line needs + @param names: an existing list (a list of strings that will be placed in "name fields" + @param values: an existing list (a list of strings that will be placed in the value part of the name/value field + @param line: the line of text we want to add + """ + + current_index = len(names) - 1 + indents = DungeonView.indent(level) + if len(names[current_index]) + len(line) + len(indents) <= 255 and len(names[current_index]) == 0: + names[current_index] += "\n{}{}".format(indents, line) + elif len(values[current_index]) + len(line) + len(indents) <= 1023: + values[current_index] += "\n{}{}".format(indents, line) + else: + names.append("") + values.append("") + DungeonView.embed_helper(level, names, values, line) + + @staticmethod + def embed(state: DungeonViewState): + fields = [] + mb = MonsterBehavior() + encounter_model = state.encounter + if encounter_model.enemy_data is not None and encounter_model.enemy_data.behavior is not None: + mb.ParseFromString(encounter_model.enemy_data.behavior) + else: + mb = None + monster = process_monster(mb, encounter_model, state.database) + + monster_embed: Embed = DungeonView.make_embed( + monster, verbose=state.verbose, spawn=[state.floor_index + 1, state.num_spawns], + floor=[state.floor, state.num_floors], technical=state.technical + )[state.page] + + hp = f'{monster.hp:,}' + atk = f'{monster.atk:,}' + defense = f'{monster.defense:,}' + turns = f'{monster.turns:,}' + title = monster_embed.title + desc = monster_embed.description + me_fields = monster_embed.fields + + for f in me_fields: + fields.append( + EmbedField(f.name, Box(*[f.value])) + ) + return EmbedView( + EmbedMain( + title=title, + description=desc, + ), + embed_fields=fields, + embed_footer=embed_footer_with_state(state) + ) + + @staticmethod + def make_embed_description(dungeon_monster, desc=""): + return "HP: {} ATK: {} DEF: {} CD: {}{}".format(f'{dungeon_monster.hp:,}', + f'{dungeon_monster.atk:,}', + f'{dungeon_monster.defense:,}', + f'{dungeon_monster.turns:,}', desc) + + @staticmethod + def make_embed(dungeon_monster: DungeonMonster, verbose: bool = False, spawn: List[int] = None, + floor: List[int] = None, technical: int = None) -> List[discord.Embed]: + """ + When called this generates an embed that displays the encounter (what is seen in dungeon_info). + @param dungeon_monster: the processed monster to make an embed for + @param verbose: whether or not to display effect text + @param spawn: used to show what spawn this is on a floor [spawn, max] -> "spawn/max" + @param floor: used to show what floor this is [current floor, number of floors] + @param technical: if the dungeon is a technical dungeon we don't display skills + """ + + embeds = [] + + desc = "" + + # We create two pages as monsters at max will only ever require two pages of embeds + if spawn is not None: + embed = discord.Embed( + title="Floor: {}/{} Spawn: {}/{} Page: ".format( + floor[0], floor[1], + spawn[0], spawn[1] + ), + description='{}\n{}'.format( + MonsterHeader.text_with_emoji(dungeon_monster.monster), + DungeonView.make_embed_description(dungeon_monster, desc)) + ) + else: + embed = discord.Embed( + title="Enemy:{} at Level: {}".format( + MonsterHeader.text_with_emoji(dungeon_monster.monster), + dungeon_monster.level), + description=DungeonView.make_embed_description(dungeon_monster, desc) + ) + + embeds.append(embed) + embeds.append(embed.copy()) + + if technical == 0: + embeds[0].title += " 1/1" + embeds[1].title += " 2/1" + return embeds + embeds[0].title += " 1/" + embeds[1].title += " 2/" + # We collect text from the skills and groups of skills + lines = [] + for group in dungeon_monster.groups: + group.give_string2(lines, 0, verbose) + + # We add the lines using embed_helper + names = [""] + values = [""] + fields = 0 + current_embed = 0 + length = len(embed.title) + len(embed.description) + 1 + first = None + for line in lines: + for comp in line[1]: + DungeonView.embed_helper(line[0], names, values, comp) + + # We add the name/value pairs to the actual embed. If needed we go to the second page + if len(values[len(values) - 1]) == 0: + values[len(values) - 1] = '\u200b' + for index in range(len(names)): + name = names[index] + value = values[index] + if len(name) > 0: + temp_length = length + len(name) + len(value) + if temp_length > 6000: + current_embed += 1 + length = len(embed.title) + len(embed.description) + embeds[current_embed].add_field(name=name, value=value, inline=False) + length += len(name) + len(value) + fields += 1 + # embed.add_field(name=k, value=content, inline=False) + + if current_embed == 0: + embeds[0].title += '1' + embeds[1].title += '1' + + # for fun + random_index = random.randint(0, len(DungeonView.egg_text) - 1) + embeds[1].add_field(name=DungeonView.egg_text[random_index], value="Come back when you see 1/2 for page!") + else: + embeds[0].title += '2' + embeds[1].title += '2' + return embeds diff --git a/dungeoncog/view/simple.py b/dungeoncog/view/simple.py new file mode 100644 index 00000000..d389b2a5 --- /dev/null +++ b/dungeoncog/view/simple.py @@ -0,0 +1,46 @@ +from discordmenu.embed.base import Box +from discordmenu.embed.components import EmbedField, EmbedMain +from discordmenu.embed.view import EmbedView +from discordmenu.embed.view_state import ViewState +from tsutils.menu.components.footers import embed_footer_with_state + + +class SimpleViewState(ViewState): + def __init__(self, original_author_id, menu_type, raw_query, + message_list, index): + super().__init__(original_author_id, menu_type, raw_query) + self.message_list = message_list + self.index = index + + def serialize(self): + ret = super().serialize() + ret.update({ + 'message_list': self.message_list, + 'index': self.index + }) + return ret + + @classmethod + async def deserialize(cls, dbcog, ims: dict, inc): + original_author_id = ims['original_author_id'] + menu_type = ims['menu_type'] + raw_query = ims.get('raw_query') + return cls(original_author_id, menu_type, raw_query, ims.get('message_list'), ims.get('index') + inc) + + +class SimpleView: + VIEW_TYPE = 'SimpleText' + + @staticmethod + def embed(state: SimpleViewState): + fields = [ + EmbedField("Title", Box(*["1", '2'])) + ] + return EmbedView( + EmbedMain( + color="black", + description=state.message_list[state.index] + ), + embed_fields=fields, + embed_footer=embed_footer_with_state(state), + ) diff --git a/fancysay/__init__.py b/fancysay/__init__.py deleted file mode 100644 index 247cfe96..00000000 --- a/fancysay/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .fancysay import * - - -def setup(bot): - bot.add_cog(FancySay(bot)) diff --git a/fancysay/fancysay.py b/fancysay/fancysay.py deleted file mode 100644 index 4ff5f01b..00000000 --- a/fancysay/fancysay.py +++ /dev/null @@ -1,144 +0,0 @@ -import asyncio - -import discord -from redbot.core import checks -from redbot.core import commands -from redbot.core.bot import Red -from redbot.core.utils.chat_formatting import inline - -from rpadutils import rpadutils -from rpadutils import char_to_emoji - - -class FancySay(commands.Cog): - def __init__(self, bot: Red, *args, **kwargs): - super().__init__(*args, **kwargs) - self.bot = bot - - @commands.group() - @commands.guild_only() - @checks.mod_or_permissions(manage_messages=True) - async def fancysay(self, ctx): - """Make the bot say fancy things (via embeds).""" - - @fancysay.command() - async def pingrole(self, ctx, role: discord.Role, *, text): - """[p]fancysay pingrole rolename this is the text to ping - - 1) Converts a role to mentionable - 2) Posts the message + ping in the current channel - 3) Sets the role to unmentionable - 4) Deletes the input message - - The role must be unmentionable before this command for safety. - """ - if role.mentionable: - await ctx.send(inline('Error: role is already mentionable')) - return - - try: - await role.edit(mentionable=True) - except Exception as ex: - await ctx.send(inline('Error: failed to set role mentionable')) - if ex.text == "Missing Permissions": - message = await ctx.send(inline('Make sure this bot\'s role is higher than the one you\'re mentioning')) - await asyncio.sleep(3) - await message.delete() - return - - await ctx.message.delete() - await asyncio.sleep(1) - await ctx.send('From {}:\n{}\n{}'.format(ctx.author.mention, role.mention, text)) - - try: - await role.edit(mentionable=False) - except Exception as ex: - await ctx.send(inline('Error: failed to set role unmentionable')) - print(ex) - return - - @fancysay.command() - async def emoji(self, ctx, *, text): - """Speak the provided text as emojis, deleting the original request""" - await ctx.message.delete() - new_msg = "" - for char in text: - if char.isalpha(): - new_msg += char_to_emoji(char) + ' ' - elif char == ' ': - new_msg += ' ' - elif char.isspace(): - new_msg += char - - if len(new_msg): - await ctx.send(new_msg) - - @fancysay.command() - async def title_description_image_footer(self, ctx, title, description, image, footer): - """[title] [description] [image_url] [footer_text] - - You must specify a title. You can omit any of description, image, or footer. - To omit an item use empty quotes. For the text fields, wrap your text in quotes. - The bot will automatically delete your 'say' command if it can - - e.g. say with all fields: - fancysay title_description_image_footer "My title text" "Description text" "xyz.com/image.png" "source: xyz.com" - - e.g. say with only title and image: - fancysay title_descirption_image_footer "My title" "" "xyz.com/image.png" "" - """ - - embed = discord.Embed() - if len(title): - embed.title = title - if len(description): - embed.description = description - if len(image): - embed.set_image(url=image) - if len(footer): - embed.set_footer(text=footer) - - try: - await ctx.send(embed=embed) - await ctx.message.delete() - except Exception as error: - print("failed to fancysay", error) - - - - - - @commands.command(aliases = ["parrot", "repeat"]) - @checks.mod_or_permissions(manage_messages=True) - async def say(self, ctx, *, message): - """Make Miru parrot a phrase.""" - message = self.emojify(message) - await ctx.send(message) - - @commands.command(aliases = ["testparrot", "testrepeat"]) - @checks.mod_or_permissions(manage_messages=True) - async def testsay(self, ctx, *, message): - """Make Miru parrot a phrase without smart emoji replacements.""" - await ctx.send(message) - - @commands.command() - @checks.mod_or_permissions(manage_messages=True) - async def mask(self, ctx, *, message): - """Sends a message as Miru.""" - message = self.emojify(message) - await ctx.message.delete() - await ctx.send(message) - - @commands.command() - @checks.mod_or_permissions(manage_messages=True) - async def yell(self, ctx, *, message): - """Yells some text.""" - message = self.emojify(message) - await ctx.send(message.upper().rstrip(",.!?")+"!!!!!!") - - def emojify(self, message): - emojis = list() - for guild in self.bot.guilds: - emojis.extend(guild.emojis) - message = rpadutils.replace_emoji_names_with_code(emojis, message) - return rpadutils.fix_emojis_for_server(emojis, message) diff --git a/feedback/__init__.py b/feedback/__init__.py new file mode 100644 index 00000000..8e829606 --- /dev/null +++ b/feedback/__init__.py @@ -0,0 +1,7 @@ +from .feedback import Feedback + +__red_end_user_data_statement__ = "No personal data is stored." + + +async def setup(bot): + bot.add_cog(Feedback(bot)) if not __import__('asyncio').iscoroutinefunction(bot.add_cog) else await bot.add_cog(Feedback(bot)) diff --git a/feedback/feedback.py b/feedback/feedback.py new file mode 100644 index 00000000..fc55c8b2 --- /dev/null +++ b/feedback/feedback.py @@ -0,0 +1,168 @@ +from io import BytesIO + +import discord +from redbot.core import checks, commands, Config +from redbot.core.utils.chat_formatting import inline +from tsutils.cog_settings import CogSettings + + +class Feedback(commands.Cog): + """Feedback Cog""" + + def __init__(self, bot, *args, **kwargs): + super().__init__(*args, **kwargs) + self.bot = bot + self.settings = FeedbackSettings("feedback") + self.config = Config.get_conf(self, identifier=73308437) + self.config.register_global(feedback_server="") + + self.sessions = set() + + async def red_get_data_for_user(self, *, user_id): + """Get a user's personal data.""" + data = "No data is stored for user with ID {}.\n".format(user_id) + return {"user_data.txt": BytesIO(data.encode())} + + async def red_delete_data_for_user(self, *, requester, user_id): + """Delete a user's personal data. + + No personal data is stored in this cog. + """ + return + + async def _send_feedback(self, ctx, message: str, feedback_channel, success_message: str): + if feedback_channel is None: + raise commands.UserFeedbackCheckFailure("Feedback channel not set") + + guild = ctx.guild + author = ctx.author + footer = "User ID: " + str(author.id) + + if guild: + source = "from {}".format(guild) + footer += " | Guild ID: " + str(guild.id) + else: + source = "through DM" + + description = "Sent by {} {}".format(author, source) + + e = discord.Embed(description=message) + if author.avatar.url: + e.set_author(name=description, icon_url=ctx.author.avatar.url) + else: + e.set_author(name=description) + e.set_footer(text=footer) + + try: + await feedback_channel.send(embed=e) + except: + await ctx.send(inline("I'm unable to deliver your message. Sorry.")) + else: + await ctx.send(("Your message has been sent." + " Abusing this feature will result in a blacklist.") + + success_message) + + @commands.command() + @checks.bot_has_permissions(embed_links=True) + async def credits(self, ctx): + """Shows info about this bot""" + author_repo = "https://github.com/Twentysix26" + red_repo = author_repo + "/Red-DiscordBot" + rpad_invite = "https://discord.gg/pad" + + about = ( + "This is an instance of [the Red Discord bot]({}), " + "use the '{}info' command for more info. " + "The various PAD related cogs were created by Aradia Megido and tactical_retreat. " + "This bot was created for the [PAD Community Server Discord]({}) but " + "is available for other servers on request." + "".format(red_repo, ctx.prefix, rpad_invite)) + + avatar = ( + "Bot avatars supplied by:\n" + "\t[Tsubaki]({}): {}").format("https://twitter.com/_violebot", "Violebot") + + using = ( + "You can use `{0.prefix}help` to get a full list of commands.\n" + "Use `{0.prefix}userhelp` to get a summary of useful user features.\n" + "Use `{0.prefix}modhelp` to get info on moderator-only features." + ) + + embed = discord.Embed() + embed.add_field(name="Instance owned by", value='The Tsubaki Team') + embed.add_field(name="About the bot", value=about, inline=False) + embed.add_field(name="Using the bot", value=using.format(ctx), inline=False) + embed.add_field(name="Avatar credits", value=avatar, inline=False) + embed.set_thumbnail(url=self.bot.user.avatar.url) + + await ctx.send(embed=embed) + + @commands.group(invoke_without_command=True) + @commands.cooldown(1, 60, commands.BucketType.user) + async def feedback(self, ctx, *, message: str): + """Provide feedback on the bot. + + Use this command to provide public feedback on the bot. + """ + feedback_channel = self.bot.get_channel(int(self.settings.get_feedback_channel())) + await self._send_feedback(ctx, message, feedback_channel, + ("\nJoin the Tsubaki Server to see any responses.\n" + "{}").format(await self.config.feedback_server())) + + @commands.command() + @commands.guild_only() + @checks.is_owner() + async def setfeedbackchannel(self, ctx, channel: discord.TextChannel): + """Set the feedback destination channel.""" + self.settings.set_feedback_channel(channel.id) + await ctx.tick() + + @commands.command(aliases=['mamafeedback', 'renifeedback']) + @commands.cooldown(1, 60, commands.BucketType.user) + async def blogfeedback(self, ctx, *, message: str): + """Provide feedback on Reni's blog or translations. + + Use this command to submit feedback on https://pad.protic.site or the JP translations. + """ + feedback_channel = self.bot.get_channel(int(self.settings.get_blog_feedback_channel())) + await self._send_feedback(ctx, message, feedback_channel, + " Join the PDX Server to see any responses ({0.prefix}pdx).".format(ctx)) + + @commands.command() + @commands.guild_only() + @checks.is_owner() + async def setblogfeedbackchannel(self, ctx, channel: discord.TextChannel): + """Set the blog feedback destination channel.""" + self.settings.set_blog_feedback_channel(channel.id) + await ctx.tick() + + @feedback.command() + @checks.is_owner() + @commands.cooldown(1, 0, commands.BucketType.user) + async def setserverinvite(self, ctx, *, invite): + """Set the blog feedback destination channel.""" + await self.config.feedback_server.set(invite) + await ctx.tick() + + +class FeedbackSettings(CogSettings): + def make_default_settings(self): + config = { + 'feedback_channel': None, + 'blog_feedback_channel': None, + } + return config + + def get_feedback_channel(self): + return self.bot_settings.get('feedback_channel') + + def set_feedback_channel(self, channel_id: int): + self.bot_settings['feedback_channel'] = str(channel_id) + self.save_settings() + + def get_blog_feedback_channel(self): + return self.bot_settings.get('blog_feedback_channel') + + def set_blog_feedback_channel(self, channel_id: int): + self.bot_settings['blog_feedback_channel'] = str(channel_id) + self.save_settings() diff --git a/feedback/info.json b/feedback/info.json new file mode 100644 index 00000000..f218d55c --- /dev/null +++ b/feedback/info.json @@ -0,0 +1,26 @@ +{ + "author": [ + "The Tsubaki Bot Team" + ], + "description": "Tsubaki feedback and credits", + "end_user_data_statement": "No personal data is stored.", + "install_msg": "Use [p]setfeedbackchannel and [p]setblogfeedbackchannel to set the feedback channels.", + "short": "Feedback and credits", + "min_bot_version": "3.4.0", + "max_bot_version": "0.0.0", + "min_python_version": [ + 3, + 8, + 1 + ], + "hidden": false, + "disabled": false, + "required_cogs": {}, + "requirements": [ + "tsutils" + ], + "tags": [ + "Tsubaki", + "feedback" + ] +} diff --git a/info.json b/info.json index 05ed0357..a11b82f8 100644 --- a/info.json +++ b/info.json @@ -1,3 +1,7 @@ { - "author" : ["The Miru Bot Team"] + "author": [ + "The Tsubaki Bot Team" + ], + "description": "A collection of cogs related to the mobile game Puzzle and Dragons, along with a few unrelated games.", + "short": "The main Tsubotki repo." } diff --git a/memes/__init__.py b/memes/__init__.py deleted file mode 100644 index 9b36324a..00000000 --- a/memes/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .memes import * - - -def setup(bot): - bot.add_cog(Memes(bot)) diff --git a/memes/memes.py b/memes/memes.py deleted file mode 100644 index 8bcf8d68..00000000 --- a/memes/memes.py +++ /dev/null @@ -1,227 +0,0 @@ -import json -import os -import re - -import discord -from redbot.core import checks, data_manager -from redbot.core import commands - -from rpadutils import CogSettings, get_role_from_id, get_role, safe_read_json - - -def _data_file(file_name: str) -> str: - return os.path.join(str(data_manager.cog_data_path(raw_name='memes')), file_name) - - -class Memes(commands.Cog): - """Custom memes.""" - - def __init__(self, bot, *args, **kwargs): - super().__init__(*args, **kwargs) - self.bot = bot - self.file_path = _data_file('commands.json') - self.c_commands = safe_read_json(self.file_path) - self.c_commands = {int(k): v for k, v in self.c_commands.items()} # Fix legacy issues - self.settings = MemesSettings("memes") - - @commands.command() - @commands.guild_only() - @checks.mod_or_permissions(administrator=True) - async def addmeme(self, ctx, command: str, *, text): - """Adds a meme - - Example: - [p]addmeme yourmeme Text you want - - Memes can be enhanced with arguments: - https://twentysix26.github.io/Red-Docs/red_guide_command_args/ - """ - guild = ctx.guild - command = command.lower() - if command in self.bot.all_commands.keys(): - await ctx.send("That meme is already a standard command.") - return - if not guild.id in self.c_commands: - self.c_commands[guild.id] = {} - cmdlist = self.c_commands[guild.id] - if command not in cmdlist: - cmdlist[command] = text - self.c_commands[guild.id] = cmdlist - json.dump(self.c_commands, open(self.file_path, 'w+')) - await ctx.send("Custom command successfully added.") - else: - await ctx.send("This command already exists. Use editmeme to edit it.") - - @commands.command() - @commands.guild_only() - @checks.mod_or_permissions(administrator=True) - async def editmeme(self, ctx, command: str, *, text): - """Edits a meme - - Example: - [p]editmeme yourcommand Text you want - """ - guild = ctx.guild - command = command.lower() - if guild.id in self.c_commands: - cmdlist = self.c_commands[guild.id] - if command in cmdlist: - cmdlist[command] = text - self.c_commands[guild.id] = cmdlist - json.dump(self.c_commands, open(self.file_path, 'w+')) - await ctx.send("Custom command successfully edited.") - else: - await ctx.send("That command doesn't exist. Use addmeme [command] [text]") - else: - await ctx.send("There are no custom memes in this server. Use addmeme [command] [text]") - - @commands.command() - @commands.guild_only() - @checks.mod_or_permissions(administrator=True) - async def delmeme(self, ctx, command: str): - """Deletes a meme - - Example: - [p]delmeme yourcommand""" - guild = ctx.guild - command = command.lower() - if guild.id in self.c_commands: - cmdlist = self.c_commands[guild.id] - if command in cmdlist: - cmdlist.pop(command, None) - self.c_commands[guild.id] = cmdlist - json.dump(self.c_commands, open(self.file_path, 'w+')) - await ctx.send("Custom meme successfully deleted.") - else: - await ctx.send("That meme doesn't exist.") - else: - await ctx.send("There are no custom memes in this server. Use addmeme [command] [text]") - - @commands.command() - @commands.guild_only() - @checks.mod_or_permissions(administrator=True) - async def setmemerole(self, ctx, rolename: str): - """Sets the meme role - - Example: - [p]setmemerole Regular""" - - role = get_role(ctx.guild.roles, rolename) - self.settings.setPrivileged(ctx.guild.id, role.id) - await ctx.send("done") - - @commands.command() - @commands.guild_only() - async def memes(self, ctx): - """Shows custom memes list""" - guild = ctx.guild - if guild.id in self.c_commands and self.c_commands[guild.id]: - cmdlist = self.c_commands[guild.id] - i = 0 - msg = ["```Custom memes:\n"] - for cmd in sorted([cmd for cmd in cmdlist.keys()]): - if len(msg[i]) + len(ctx.prefix) + len(cmd) + 5 > 2000: - msg[i] += "```" - i += 1 - msg.append("``` {}{}\n".format(ctx.prefix, cmd)) - else: - msg[i] += " {}{}\n".format(ctx.prefix, cmd) - msg[i] += "```" - for cmds in msg: - await ctx.author.send(cmds) - else: - await ctx.send("There are no custom memes in this server. Use addmeme [command] [text]") - - @commands.Cog.listener('on_message') - async def checkCC(self, message): - if len(message.content) < 2 or isinstance(message.channel, discord.abc.PrivateChannel): - return - - guild = message.guild - prefix = await self.get_prefix(message) - - if not prefix: - return - - # MEME CODE - role_id = self.settings.getPrivileged(guild.id) - if role_id is not None: - role = get_role_from_id(self.bot, guild, role_id) - if role not in message.author.roles: - return - - # MEME CODE - if guild.id in self.c_commands and not message.author.bot: - cmdlist = self.c_commands[guild.id] - cmd = message.content[len(prefix):] - if cmd in cmdlist.keys(): - cmd = cmdlist[cmd] - cmd = self.format_cc(cmd, message) - await message.channel.send(cmd) - elif cmd.lower() in cmdlist.keys(): - cmd = cmdlist[cmd.lower()] - cmd = self.format_cc(cmd, message) - await message.channel.send(cmd) - - async def get_prefix(self, message): - for p in await self.bot.get_prefix(message): - if message.content.startswith(p): - return p - return False - - def format_cc(self, command, message): - results = re.findall(r"\{([^}]+)\}", command) - for result in results: - param = self.transform_parameter(result, message) - command = command.replace("{" + result + "}", param) - return command - - def transform_parameter(self, result, message): - """ - For security reasons only specific objects are allowed - Internals are ignored - """ - raw_result = "{" + result + "}" - objects = { - "message": message, - "author": message.author, - "channel": message.channel, - "server": message.guild - } - if result in objects: - return str(objects[result]) - try: - first, second = result.split(".") - except ValueError: - return raw_result - if first in objects and not second.startswith("_"): - first = objects[first] - else: - return raw_result - return str(getattr(first, second, raw_result)) - - -class MemesSettings(CogSettings): - def make_default_settings(self): - config = { - 'configs': {} - } - return config - - def guildConfigs(self): - return self.bot_settings['configs'] - - def getGuild(self, guild_id: int): - configs = self.guildConfigs() - if guild_id not in configs: - configs[guild_id] = {} - return configs[guild_id] - - def getPrivileged(self, guild_id: int): - guild = self.getGuild(guild_id) - return guild.get('privileged') - - def setPrivileged(self, guild_id: int, role_id: int): - guild = self.getGuild(guild_id) - guild['privileged'] = role_id - self.save_settings() diff --git a/modnotes/__init__.py b/modnotes/__init__.py deleted file mode 100644 index 96ebb516..00000000 --- a/modnotes/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .modnotes import * - - -def setup(bot): - bot.add_cog(ModNotes(bot)) diff --git a/modnotes/modnotes.py b/modnotes/modnotes.py deleted file mode 100644 index a08c1b75..00000000 --- a/modnotes/modnotes.py +++ /dev/null @@ -1,122 +0,0 @@ -""" -Utilities for managing moderator notes about users. -""" -import discord -from redbot.core import checks -from redbot.core import commands -from redbot.core.bot import Red -from redbot.core.utils.chat_formatting import inline, box, pagify - -from rpadutils import CogSettings - - -class ModNotes(commands.Cog): - def __init__(self, bot: Red, *args, **kwargs): - super().__init__(*args, **kwargs) - self.bot = bot - self.settings = ModNotesSettings("modnotes") - - @commands.group(aliases=["usernote"]) - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def usernotes(self, ctx): - """Moderator notes for users. - - This module allows you to create notes to share between moderators. - """ - - @usernotes.command(name="print") - @checks.mod_or_permissions(manage_guild=True) - async def _print(self, ctx, user: discord.User): - """Print the notes for a user.""" - notes = self.settings.getNotesForUser(ctx.guild.id, user.id) - if not notes: - await ctx.send(box('No notes for {}'.format(user.name))) - return - - for idx, note in enumerate(notes): - await ctx.send(inline('Note {} of {}:'.format(idx + 1, len(notes)))) - await ctx.send(box(note)) - - @usernotes.command() - @checks.mod_or_permissions(manage_guild=True) - async def add(self, ctx, user: discord.User, *, note_text: str): - """Add a note to a user.""" - timestamp = str(ctx.message.created_at)[:-7] - msg = 'Added by {} ({}): {}'.format(ctx.author.name, timestamp, note_text) - server_id = ctx.guild.id - notes = self.settings.addNoteForUser(server_id, user.id, msg) - await ctx.send(inline('Done. User {} now has {} notes'.format(user.name, len(notes)))) - - @usernotes.command() - @checks.mod_or_permissions(manage_guild=True) - async def delete(self, ctx, user: discord.User, note_num: int): - """Delete a specific note for a user.""" - notes = self.settings.getNotesForUser(ctx.guild.id, user.id) - if len(notes) < note_num: - await ctx.send(box('Note not found for {}'.format(user.name))) - return - - note = notes[note_num - 1] - notes.remove(note) - self.settings.setNotesForUser(ctx.guild.id, user.id, notes) - await ctx.send(inline('Removed note {}. User has {} remaining.'.format(note_num, len(notes)))) - await ctx.send(box(note)) - - @usernotes.command() - @checks.mod_or_permissions(manage_guild=True) - async def list(self, ctx): - """Lists all users and note counts for the server.""" - user_notes = self.settings.getUserNotes(ctx.guild.id) - msg = 'Notes for {} users'.format(len(user_notes)) - for user_id, notes in user_notes.items(): - user = ctx.guild.get_member(user_id) - user_text = '{} ({})'.format(user.name, user.id) if user else user_id - msg += '\n\t{} : {}'.format(len(notes), user_text) - - for page in pagify(msg): - await ctx.send(box(page)) - - -class ModNotesSettings(CogSettings): - def make_default_settings(self): - config = { - 'servers': {} - } - return config - - def servers(self): - return self.bot_settings['servers'] - - def getServer(self, server_id): - servers = self.servers() - if server_id not in servers: - servers[server_id] = {} - return servers[server_id] - - def getUserNotes(self, server_id): - server = self.getServer(server_id) - key = 'user_notes' - if key not in server: - server[key] = {} - return server[key] - - def getNotesForUser(self, server_id, user_id): - user_notes = self.getUserNotes(server_id) - return user_notes.get(user_id, []) - - def setNotesForUser(self, server_id, user_id, notes): - user_notes = self.getUserNotes(server_id) - - if notes: - user_notes[user_id] = notes - else: - user_notes.pop(user_id, None) - self.save_settings() - return notes - - def addNoteForUser(self, server_id, user_id, note: str): - notes = self.getNotesForUser(server_id, user_id) - notes.append(note) - self.setNotesForUser(server_id, user_id, notes) - return notes diff --git a/monidlistener/__init__.py b/monidlistener/__init__.py new file mode 100644 index 00000000..e0abc280 --- /dev/null +++ b/monidlistener/__init__.py @@ -0,0 +1,7 @@ +from .monidlistener import MonIdListener + +__red_end_user_data_statement__ = "No personal data is stored." + + +async def setup(bot): + bot.add_cog(MonIdListener(bot)) if not __import__('asyncio').iscoroutinefunction(bot.add_cog) else await bot.add_cog(MonIdListener(bot)) diff --git a/monidlistener/info.json b/monidlistener/info.json new file mode 100644 index 00000000..22ec6722 --- /dev/null +++ b/monidlistener/info.json @@ -0,0 +1,21 @@ +{ + "author": ["fluff#2368"], + "description": "Gets monster names when monster ids are sent.", + "end_user_data_statement": "No personal data is stored.", + "install_msg": "", + "short": "Monster Id Lookup", + "min_bot_version": "3.4.0", + "max_bot_version": "0.0.0", + "min_python_version": [ + 3, + 8, + 1 + ], + "hidden": false, + "disabled": false, + "required_cogs": {}, + "requirements": ["tsutils"], + "tags": [ + "pad" + ] + } diff --git a/monidlistener/monidlistener.py b/monidlistener/monidlistener.py new file mode 100644 index 00000000..9d3e7e0b --- /dev/null +++ b/monidlistener/monidlistener.py @@ -0,0 +1,146 @@ +import re +from io import BytesIO +from typing import TYPE_CHECKING + +from redbot.core import Config, checks, commands +from tsutils.tsubaki.monster_header import MonsterHeader +from tsutils.user_interaction import indented, confirmation_message, cancellation_message, send_cancellation_message + +if TYPE_CHECKING: + from dbcog.models.monster_model import MonsterModel + from dbcog.monster_graph import MonsterGraph + + +class MonIdListener(commands.Cog): + """Monster Name from ID""" + + def __init__(self, bot, *args, **kwargs): + super().__init__(*args, **kwargs) + self.bot = bot + + self.config = Config.get_conf(self, identifier=10100779) + self.config.register_channel(enabled=False) + + async def red_get_data_for_user(self, *, user_id): + """Get a user's personal data.""" + data = "No data is stored for user with ID {}.\n".format(user_id) + return {"user_data.txt": BytesIO(data.encode())} + + async def red_delete_data_for_user(self, *, requester, user_id): + """Delete a user's personal data. + + No personal data is stored in this cog. + """ + return + + @commands.Cog.listener('on_message') + async def on_message(self, message): + channel = message.channel + content = message.content + content = re.sub(r'297', '', content) + if not await self.config.channel(channel).enabled(): + return + if message.guild is None: # dms + return + if message.author == self.bot.user: # dont reply to self + return + if await self.is_command(message): # skip commands + return + dbcog = self.bot.get_cog("DBCog") + if dbcog is None: + await channel.send("Error: DBCog Cog not loaded. Please alert a bot owner.") + return + if re.search(r'\b\d{3}.{0,2}\d{3}.{0,2}\d{3}\b', content): # friend code + return + if "+" in content or "plus" in content: + return + if re.search(r'\b\d{3,4}\b', content): + matches = re.findall(r'\b\d{3,4}\b', content) + ret = "" + for i in matches: + if i == "100": # skip when people say "is 100 mp or over" ~~and ryan's posts~~ + continue + m: "MonsterModel" = dbcog.get_monster(int(i)) + if m is None: # monster not found + continue + dbcog.database.graph: "MonsterGraph" + if m.sell_mp < 100: + tradeable_text = '' + else: + evo_gem_text = '' + if m.evo_gem_id is not None: + evo_gem: "MonsterModel" = dbcog.get_monster(m.evo_gem_id) + if evo_gem.sell_mp < 100: + evo_gem_text = ', but it has a tradable evo gem!' + else: + evo_gem_text = ', and its evo gem is also not tradable' + + tradeable_text = " (untradable{})".format(evo_gem_text) + + ret += "{}{}\n".format( + MonsterHeader.text_with_emoji(m), + tradeable_text + ) + if ret != "": + await channel.send(ret) + + @commands.group(aliases=['monidlistener']) + async def midlistener(self, ctx): + """Commands pertaining to monster id lookup""" + + @midlistener.command() + @checks.admin_or_permissions(manage_messages=True) + async def enable(self, ctx): + """Enable monster ID listener in this channel""" + await self.config.channel(ctx.channel).enabled.set(True) + await ctx.send("Enabled monster ID listener in this channel.") + + @midlistener.command() + @checks.admin_or_permissions(manage_messages=True) + async def disable(self, ctx): + """Disable monster ID listener in this channel""" + await self.config.channel(ctx.channel).enabled.set(False) + await ctx.send("Disabled monster ID listener in this channel.") + + async def is_command(self, msg): + prefixes = await self.bot.get_valid_prefixes() + for p in prefixes: + if msg.content.startswith(p): + return True + return False + + @commands.command(aliases=['tradable']) + async def tradeable(self, ctx, *, query: str): + """Look up if a card is tradeable""" + dbcog = self.bot.get_cog("DBCog") + monster = await dbcog.find_monster(query) + if monster is None: + return await send_cancellation_message( + ctx, f"Sorry, your query `{query}` didn't match any results :(") + + if monster.sell_mp < 100: + return await self._send_tradeable_message( + ctx, monster, indented(confirmation_message("This card is tradable!"))) + + ret = [ + indented(cancellation_message("This card is **not tradable**.")) + ] + + base_monster = dbcog.get_monster(monster.base_evo_id) + if base_monster.sell_mp < 100: + ret.append(indented(confirmation_message( + "Other forms of this card are tradable."), level=2)) + + if monster.evo_gem_id is not None: + evo_gem: "MonsterModel" = dbcog.get_monster(monster.evo_gem_id) + if evo_gem.sell_mp < 100: + ret.append(indented(confirmation_message( + "Evo gem for this card is tradable."), level=2)) + else: + ret.append(indented(cancellation_message( + "Evo gem for this card is not tradable."), level=2)) + return await self._send_tradeable_message(ctx, monster, '\n'.join(ret)) + + @staticmethod + async def _send_tradeable_message(ctx, monster: "MonsterModel", msg: str): + await ctx.send("{}\n{}".format(MonsterHeader.text_with_emoji(monster), msg)) diff --git a/msgutils/__init__.py b/msgutils/__init__.py deleted file mode 100644 index bdfe8cd4..00000000 --- a/msgutils/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .msgutils import * - - -def setup(bot): - bot.add_cog(MsgUtils(bot)) diff --git a/msgutils/msgutils.py b/msgutils/msgutils.py deleted file mode 100644 index 66650c99..00000000 --- a/msgutils/msgutils.py +++ /dev/null @@ -1,85 +0,0 @@ -import asyncio -import datetime -import re - -import discord -from redbot.core.bot import Red - -from redbot.core import checks, modlog -from redbot.core import commands -from redbot.core.utils.chat_formatting import inline, box, pagify - - -class MsgUtils(commands.Cog): - def __init__(self, bot: Red, *args, **kwargs): - super().__init__(*args, **kwargs) - self.bot = bot - - @commands.command() - @checks.mod_or_permissions(manage_guild=True) - async def editmsg(self, ctx, channel: discord.TextChannel, msg_id: int, *, new_msg: str): - """Given a channel and an ID for a message printed in that channel, replaces it. - - To find a message ID, enable developer mode in Discord settings and - click the ... on a message. - """ - try: - msg = await channel.fetch_message(msg_id) - except discord.NotFound: - await ctx.send(inline('Cannot find that message, check the channel and message id')) - return - except discord.Forbidden: - await ctx.send(inline('No permissions to do that')) - return - if msg.author.id != self.bot.user.id: - await ctx.send(inline('Can only edit messages I own')) - return - - await msg.edit(content=new_msg) - await ctx.send(inline('done')) - - @commands.command() - @checks.mod_or_permissions(manage_guild=True) - async def dumpchannel(self, ctx, channel: discord.TextChannel, msg_id: int = None): - """Given a channel and an ID for a message printed in that channel, dumps it - boxed with formatting escaped and some issues cleaned up. - - To find a message ID, enable developer mode in Discord settings and - click the ... on a message. - """ - await self._dump(ctx, channel, msg_id) - - @commands.command() - async def dumpmsg(self, ctx, msg_id: int = None): - """Given an ID for a message printed in the current channel, dumps it - boxed with formatting escaped and some issues cleaned up. - - To find a message ID, enable developer mode in Discord settings and - click the ... on a message. - """ - await self._dump(ctx, ctx.channel, msg_id) - - async def _dump(self, ctx, channel: discord.TextChannel = None, msg_id: int = None): - if msg_id: - msg = await channel.fetch_message(msg_id) - else: - msg_limit = 2 if channel == ctx.channel else 1 - async for message in channel.history(limit=msg_limit): - msg = message - content = msg.content.strip() - content = re.sub(r'<(:[0-9a-z_]+:)\d{18}>', r'\1', content, flags=re.IGNORECASE) - content = box(content.replace('`', u'\u200b`')) - await ctx.send(content) - - @commands.command() - async def dumpmsgexact(self, ctx, msg_id: int): - """Given an ID for a message printed in the current channel, dumps it - boxed with formatting escaped. - - To find a message ID, enable developer mode in Discord settings and - click the ... on a message. - """ - msg = await ctx.channel.fetch_message(msg_id) - content = msg.content.strip() - content = box(content.replace('`', u'\u200b`')) - await ctx.send(content) diff --git a/padboard/__init__.py b/padboard/__init__.py index ec1690d2..fcf19345 100644 --- a/padboard/__init__.py +++ b/padboard/__init__.py @@ -1,5 +1,7 @@ -from .padboard import * +from .padboard import PadBoard +__red_end_user_data_statement__ = "No personal data is stored persistantly." -def setup(bot): - bot.add_cog(PadBoard(bot)) + +async def setup(bot): + bot.add_cog(PadBoard(bot)) if not __import__('asyncio').iscoroutinefunction(bot.add_cog) else await bot.add_cog(PadBoard(bot)) diff --git a/padboard/info.json b/padboard/info.json new file mode 100644 index 00000000..3c40153a --- /dev/null +++ b/padboard/info.json @@ -0,0 +1,28 @@ +{ + "author": [ + "The Tsubaki Bot Team" + ], + "description": "Turn a screenshot into a P&D simulation via DawnGlare.", + "end_user_data_statement": "No personal data is stored persistantly.", + "install_msg": "Use [p]set_tflite_path to set up your tflite path. (You need to get this from The Tsubaki Bot Team)", + "short": "Dawnglare utils", + "min_bot_version": "3.4.0", + "max_bot_version": "0.0.0", + "min_python_version": [ + 3, + 8, + 1 + ], + "hidden": false, + "disabled": false, + "required_cogs": {}, + "requirements": [ + "tsutils", + "Pillow", + "tensorflow", + "numpy" + ], + "tags": [ + "PAD" + ] +} diff --git a/padboard/padboard.py b/padboard/padboard.py index 61d1041e..c6455615 100644 --- a/padboard/padboard.py +++ b/padboard/padboard.py @@ -1,33 +1,48 @@ -import os -from collections import defaultdict -from collections import deque - import aiohttp import cv2 import discord import numpy as np -from redbot.core import checks -from redbot.core import commands -from redbot.core.utils.chat_formatting import inline +from tsutils.formatting import extract_image_url -from padvision import padvision -from rpadutils import rpadutils +from .padvision import NeuralClassifierBoardExtractor +from io import BytesIO +from collections import defaultdict +from collections import deque +from redbot.core import Config, checks, commands +from redbot.core.utils.chat_formatting import inline -DATA_DIR = os.path.join('data', 'padboard') -DAWNGLARE_BOARD_TEMPLATE = "https://candyninja001.github.io/Puzzled/?patt={}" -MIRUGLARE_BOARD_TEMPLATE = "https://storage.googleapis.com/mirubot/websites/padsim/index.html?patt={}" +DAWNGLARE_BOARD_TEMPLATE = "https://pad.dawnglare.com/?patt={}" +CNINJA_BOARD_TEMPLATE = "https://candyninja001.github.io/Puzzled/?patt={}" class PadBoard(commands.Cog): + """Dawnglare Utilities""" + def __init__(self, bot, *args, **kwargs): super().__init__(*args, **kwargs) self.bot = bot + + self.config = Config.get_conf(self, identifier=94080420) + self.config.register_global(tflite_path="") + self.logs = defaultdict(lambda: deque(maxlen=1)) + async def red_get_data_for_user(self, *, user_id): + """Get a user's personal data.""" + data = "No data is stored for user with ID {}.\n".format(user_id) + return {"user_data.txt": BytesIO(data.encode())} + + async def red_delete_data_for_user(self, *, requester, user_id): + """Delete a user's personal data. + + No personal data is stored in this cog. + """ + return + @commands.Cog.listener("on_message") async def log_message(self, message): - url = rpadutils.extract_image_url(message) + url = extract_image_url(message) if url: self.logs[message.author.id].append(url) @@ -36,6 +51,11 @@ async def log_message(self, message): async def padboard(self, ctx): """PAD board utilities.""" + @padboard.command() + async def set_tflite_path(self, ctx, *, path): + await self.config.tflite_path.set(path) + await ctx.tick() + def find_image(self, user_id): urls = list(self.logs[user_id]) if urls: @@ -62,22 +82,28 @@ async def dawnglare(self, ctx, user: discord.Member = None): if not image_data: return - img_board_nc = self.nc_classify(image_data) + try: + img_board_nc = await self.nc_classify(image_data) + except IOError: + await ctx.send("PadVision not loaded.") + return + + if not img_board_nc: + await ctx.send(inline("TFLite path not set.")) + return board_text_nc = ''.join([''.join(r) for r in img_board_nc]) # Convert O (used by padvision code) to X (used by Puzzled for bombs) board_text_nc = board_text_nc.replace('o', 'x') - img_url = DAWNGLARE_BOARD_TEMPLATE.format(board_text_nc) - img_url2 = MIRUGLARE_BOARD_TEMPLATE.format(board_text_nc) - - msg = '{}\n{}'.format(img_url, img_url2) + msg = DAWNGLARE_BOARD_TEMPLATE.format(board_text_nc) + msg += '\n' + CNINJA_BOARD_TEMPLATE.format(board_text_nc) await ctx.send(msg) async def get_recent_image(self, ctx, user: discord.Member = None, message: discord.Message = None): user_id = user.id if user else ctx.author.id - image_url = rpadutils.extract_image_url(message) + image_url = extract_image_url(message) if image_url is None: image_url = self.find_image(user_id) @@ -96,10 +122,12 @@ async def get_recent_image(self, ctx, user: discord.Member = None, message: disc return image_data - def nc_classify(self, image_data): + async def nc_classify(self, image_data): # TODO: Test this (for TR to do) - nparr = np.fromstring(image_data, np.uint8) + nparr = np.frombuffer(image_data, np.uint8) img_np = cv2.imdecode(nparr, cv2.IMREAD_COLOR) - model_path = '/home/tactical0retreat/git/pad-models/ICN3582626462823189160/model.tflite' - img_extractor = padvision.NeuralClassifierBoardExtractor(model_path, img_np, image_data) + model_path = await self.config.tflite_path() + if not model_path: + return None + img_extractor = NeuralClassifierBoardExtractor(model_path, img_np, image_data) return img_extractor.get_board() diff --git a/padvision/padvision.py b/padboard/padvision.py similarity index 90% rename from padvision/padvision.py rename to padboard/padvision.py index 545f8a84..e591a221 100644 --- a/padvision/padvision.py +++ b/padboard/padvision.py @@ -1,17 +1,11 @@ -import io -import traceback +import logging +from io import BytesIO import PIL.Image -from redbot.core import commands ORB_IMG_SIZE = 40 - -class PadVision(commands.Cog): - def __init__(self, bot, *args, **kwargs): - super().__init__(*args, **kwargs) - self.bot = bot - +logger = logging.getLogger('red.padbot-cogs.padboard.padvision') EXTRACTABLE = 'rbgldhjpmo' @@ -108,7 +102,7 @@ class NeuralClassifierBoardExtractor(object): def __init__(self, model_path, np_img, img_bytes): self.model_path = model_path self.np_img = np_img - self.img = PIL.Image.open(io.BytesIO(img_bytes)) + self.img = PIL.Image.open(BytesIO(img_bytes)).convert('RGB') self.processed = False self.results = [['u' for x in range(6)] for y in range(5)] @@ -116,8 +110,7 @@ def process(self): try: self._process() except Exception as ex: - print("orb extractor failed " + str(ex)) - traceback.print_exc() + logger.error("orb extractor failed ", exc_info=True) def _process(self): import numpy as np @@ -126,7 +119,7 @@ def _process(self): oe = OrbExtractor(self.np_img) # Load TFLite model and allocate tensors. - interpreter = tf.contrib.lite.Interpreter(model_path=self.model_path) + interpreter = tf.lite.Interpreter(model_path=self.model_path) interpreter.allocate_tensors() # Get input and output tensors. diff --git a/padbuildimg/__init__.py b/padbuildimg/__init__.py new file mode 100644 index 00000000..732053f2 --- /dev/null +++ b/padbuildimg/__init__.py @@ -0,0 +1,7 @@ +from .padbuildimg import PadBuildImage + +__red_end_user_data_statement__ = "No personal data is stored." + + +async def setup(bot): + bot.add_cog(PadBuildImage(bot)) if not __import__('asyncio').iscoroutinefunction(bot.add_cog) else await bot.add_cog(PadBuildImage(bot)) diff --git a/padbuildimg/assets/OpenSans-ExtraBold.ttf b/padbuildimg/assets/OpenSans-ExtraBold.ttf new file mode 100644 index 00000000..21f6f84a Binary files /dev/null and b/padbuildimg/assets/OpenSans-ExtraBold.ttf differ diff --git a/padbuildimg/assets/awk/1.png b/padbuildimg/assets/awk/1.png new file mode 100644 index 00000000..8c3ee743 Binary files /dev/null and b/padbuildimg/assets/awk/1.png differ diff --git a/padbuildimg/assets/awk/10.png b/padbuildimg/assets/awk/10.png new file mode 100644 index 00000000..7c9cb7e1 Binary files /dev/null and b/padbuildimg/assets/awk/10.png differ diff --git a/padbuildimg/assets/awk/100.png b/padbuildimg/assets/awk/100.png new file mode 100644 index 00000000..5ff98442 Binary files /dev/null and b/padbuildimg/assets/awk/100.png differ diff --git a/padbuildimg/assets/awk/101.png b/padbuildimg/assets/awk/101.png new file mode 100644 index 00000000..422b256e Binary files /dev/null and b/padbuildimg/assets/awk/101.png differ diff --git a/padbuildimg/assets/awk/102.png b/padbuildimg/assets/awk/102.png new file mode 100644 index 00000000..d4a7b2fa Binary files /dev/null and b/padbuildimg/assets/awk/102.png differ diff --git a/padbuildimg/assets/awk/103.png b/padbuildimg/assets/awk/103.png new file mode 100644 index 00000000..501ed8bc Binary files /dev/null and b/padbuildimg/assets/awk/103.png differ diff --git a/padbuildimg/assets/awk/104.png b/padbuildimg/assets/awk/104.png new file mode 100644 index 00000000..13b19862 Binary files /dev/null and b/padbuildimg/assets/awk/104.png differ diff --git a/padbuildimg/assets/awk/11.png b/padbuildimg/assets/awk/11.png new file mode 100644 index 00000000..ace29d8a Binary files /dev/null and b/padbuildimg/assets/awk/11.png differ diff --git a/padbuildimg/assets/awk/12.png b/padbuildimg/assets/awk/12.png new file mode 100644 index 00000000..eea63299 Binary files /dev/null and b/padbuildimg/assets/awk/12.png differ diff --git a/padbuildimg/assets/awk/13.png b/padbuildimg/assets/awk/13.png new file mode 100644 index 00000000..8917b877 Binary files /dev/null and b/padbuildimg/assets/awk/13.png differ diff --git a/padbuildimg/assets/awk/14.png b/padbuildimg/assets/awk/14.png new file mode 100644 index 00000000..23ad2aec Binary files /dev/null and b/padbuildimg/assets/awk/14.png differ diff --git a/padbuildimg/assets/awk/15.png b/padbuildimg/assets/awk/15.png new file mode 100644 index 00000000..56a2ca82 Binary files /dev/null and b/padbuildimg/assets/awk/15.png differ diff --git a/padbuildimg/assets/awk/16.png b/padbuildimg/assets/awk/16.png new file mode 100644 index 00000000..f4e0df26 Binary files /dev/null and b/padbuildimg/assets/awk/16.png differ diff --git a/padbuildimg/assets/awk/17.png b/padbuildimg/assets/awk/17.png new file mode 100644 index 00000000..7302ff3a Binary files /dev/null and b/padbuildimg/assets/awk/17.png differ diff --git a/padbuildimg/assets/awk/18.png b/padbuildimg/assets/awk/18.png new file mode 100644 index 00000000..bb5e25f3 Binary files /dev/null and b/padbuildimg/assets/awk/18.png differ diff --git a/padbuildimg/assets/awk/19.png b/padbuildimg/assets/awk/19.png new file mode 100644 index 00000000..64f530ee Binary files /dev/null and b/padbuildimg/assets/awk/19.png differ diff --git a/padbuildimg/assets/awk/2.png b/padbuildimg/assets/awk/2.png new file mode 100644 index 00000000..c71311fb Binary files /dev/null and b/padbuildimg/assets/awk/2.png differ diff --git a/padbuildimg/assets/awk/20.png b/padbuildimg/assets/awk/20.png new file mode 100644 index 00000000..0963ada6 Binary files /dev/null and b/padbuildimg/assets/awk/20.png differ diff --git a/padbuildimg/assets/awk/21.png b/padbuildimg/assets/awk/21.png new file mode 100644 index 00000000..acfcf738 Binary files /dev/null and b/padbuildimg/assets/awk/21.png differ diff --git a/padbuildimg/assets/awk/22.png b/padbuildimg/assets/awk/22.png new file mode 100644 index 00000000..478d4559 Binary files /dev/null and b/padbuildimg/assets/awk/22.png differ diff --git a/padbuildimg/assets/awk/23.png b/padbuildimg/assets/awk/23.png new file mode 100644 index 00000000..deaf06b6 Binary files /dev/null and b/padbuildimg/assets/awk/23.png differ diff --git a/padbuildimg/assets/awk/24.png b/padbuildimg/assets/awk/24.png new file mode 100644 index 00000000..a94168e2 Binary files /dev/null and b/padbuildimg/assets/awk/24.png differ diff --git a/padbuildimg/assets/awk/25.png b/padbuildimg/assets/awk/25.png new file mode 100644 index 00000000..ac41df25 Binary files /dev/null and b/padbuildimg/assets/awk/25.png differ diff --git a/padbuildimg/assets/awk/26.png b/padbuildimg/assets/awk/26.png new file mode 100644 index 00000000..2a359a42 Binary files /dev/null and b/padbuildimg/assets/awk/26.png differ diff --git a/padbuildimg/assets/awk/27.png b/padbuildimg/assets/awk/27.png new file mode 100644 index 00000000..09c372de Binary files /dev/null and b/padbuildimg/assets/awk/27.png differ diff --git a/padbuildimg/assets/awk/28.png b/padbuildimg/assets/awk/28.png new file mode 100644 index 00000000..e11b912e Binary files /dev/null and b/padbuildimg/assets/awk/28.png differ diff --git a/padbuildimg/assets/awk/29.png b/padbuildimg/assets/awk/29.png new file mode 100644 index 00000000..ee633d66 Binary files /dev/null and b/padbuildimg/assets/awk/29.png differ diff --git a/padbuildimg/assets/awk/3.png b/padbuildimg/assets/awk/3.png new file mode 100644 index 00000000..89498c02 Binary files /dev/null and b/padbuildimg/assets/awk/3.png differ diff --git a/padbuildimg/assets/awk/30.png b/padbuildimg/assets/awk/30.png new file mode 100644 index 00000000..46cb8ff2 Binary files /dev/null and b/padbuildimg/assets/awk/30.png differ diff --git a/padbuildimg/assets/awk/31.png b/padbuildimg/assets/awk/31.png new file mode 100644 index 00000000..afd9e8e1 Binary files /dev/null and b/padbuildimg/assets/awk/31.png differ diff --git a/padbuildimg/assets/awk/32.png b/padbuildimg/assets/awk/32.png new file mode 100644 index 00000000..e6566925 Binary files /dev/null and b/padbuildimg/assets/awk/32.png differ diff --git a/padbuildimg/assets/awk/33.png b/padbuildimg/assets/awk/33.png new file mode 100644 index 00000000..715c927d Binary files /dev/null and b/padbuildimg/assets/awk/33.png differ diff --git a/padbuildimg/assets/awk/34.png b/padbuildimg/assets/awk/34.png new file mode 100644 index 00000000..90b92881 Binary files /dev/null and b/padbuildimg/assets/awk/34.png differ diff --git a/padbuildimg/assets/awk/35.png b/padbuildimg/assets/awk/35.png new file mode 100644 index 00000000..68ecbdd6 Binary files /dev/null and b/padbuildimg/assets/awk/35.png differ diff --git a/padbuildimg/assets/awk/36.png b/padbuildimg/assets/awk/36.png new file mode 100644 index 00000000..7de1b26e Binary files /dev/null and b/padbuildimg/assets/awk/36.png differ diff --git a/padbuildimg/assets/awk/37.png b/padbuildimg/assets/awk/37.png new file mode 100644 index 00000000..8440996d Binary files /dev/null and b/padbuildimg/assets/awk/37.png differ diff --git a/padbuildimg/assets/awk/38.png b/padbuildimg/assets/awk/38.png new file mode 100644 index 00000000..f9aaaf08 Binary files /dev/null and b/padbuildimg/assets/awk/38.png differ diff --git a/padbuildimg/assets/awk/39.png b/padbuildimg/assets/awk/39.png new file mode 100644 index 00000000..4a8ddc6b Binary files /dev/null and b/padbuildimg/assets/awk/39.png differ diff --git a/padbuildimg/assets/awk/4.png b/padbuildimg/assets/awk/4.png new file mode 100644 index 00000000..52ce4d7a Binary files /dev/null and b/padbuildimg/assets/awk/4.png differ diff --git a/padbuildimg/assets/awk/40.png b/padbuildimg/assets/awk/40.png new file mode 100644 index 00000000..1ca2a1de Binary files /dev/null and b/padbuildimg/assets/awk/40.png differ diff --git a/padbuildimg/assets/awk/41.png b/padbuildimg/assets/awk/41.png new file mode 100644 index 00000000..7f0f4f61 Binary files /dev/null and b/padbuildimg/assets/awk/41.png differ diff --git a/padbuildimg/assets/awk/42.png b/padbuildimg/assets/awk/42.png new file mode 100644 index 00000000..96bbe8ae Binary files /dev/null and b/padbuildimg/assets/awk/42.png differ diff --git a/padbuildimg/assets/awk/43.png b/padbuildimg/assets/awk/43.png new file mode 100644 index 00000000..aba33f37 Binary files /dev/null and b/padbuildimg/assets/awk/43.png differ diff --git a/padbuildimg/assets/awk/44.png b/padbuildimg/assets/awk/44.png new file mode 100644 index 00000000..9b6a31f4 Binary files /dev/null and b/padbuildimg/assets/awk/44.png differ diff --git a/padbuildimg/assets/awk/45.png b/padbuildimg/assets/awk/45.png new file mode 100644 index 00000000..2692be56 Binary files /dev/null and b/padbuildimg/assets/awk/45.png differ diff --git a/padbuildimg/assets/awk/46.png b/padbuildimg/assets/awk/46.png new file mode 100644 index 00000000..29b0a7f9 Binary files /dev/null and b/padbuildimg/assets/awk/46.png differ diff --git a/padbuildimg/assets/awk/47.png b/padbuildimg/assets/awk/47.png new file mode 100644 index 00000000..11a5cdef Binary files /dev/null and b/padbuildimg/assets/awk/47.png differ diff --git a/padbuildimg/assets/awk/48.png b/padbuildimg/assets/awk/48.png new file mode 100644 index 00000000..f68e34c1 Binary files /dev/null and b/padbuildimg/assets/awk/48.png differ diff --git a/padbuildimg/assets/awk/49.png b/padbuildimg/assets/awk/49.png new file mode 100644 index 00000000..374bf10f Binary files /dev/null and b/padbuildimg/assets/awk/49.png differ diff --git a/padbuildimg/assets/awk/5.png b/padbuildimg/assets/awk/5.png new file mode 100644 index 00000000..a20225c2 Binary files /dev/null and b/padbuildimg/assets/awk/5.png differ diff --git a/padbuildimg/assets/awk/50.png b/padbuildimg/assets/awk/50.png new file mode 100644 index 00000000..fd105333 Binary files /dev/null and b/padbuildimg/assets/awk/50.png differ diff --git a/padbuildimg/assets/awk/51.png b/padbuildimg/assets/awk/51.png new file mode 100644 index 00000000..654bfa32 Binary files /dev/null and b/padbuildimg/assets/awk/51.png differ diff --git a/padbuildimg/assets/awk/52.png b/padbuildimg/assets/awk/52.png new file mode 100644 index 00000000..9821ebaa Binary files /dev/null and b/padbuildimg/assets/awk/52.png differ diff --git a/padbuildimg/assets/awk/53.png b/padbuildimg/assets/awk/53.png new file mode 100644 index 00000000..9e77d423 Binary files /dev/null and b/padbuildimg/assets/awk/53.png differ diff --git a/padbuildimg/assets/awk/54.png b/padbuildimg/assets/awk/54.png new file mode 100644 index 00000000..279b9282 Binary files /dev/null and b/padbuildimg/assets/awk/54.png differ diff --git a/padbuildimg/assets/awk/55.png b/padbuildimg/assets/awk/55.png new file mode 100644 index 00000000..231f3711 Binary files /dev/null and b/padbuildimg/assets/awk/55.png differ diff --git a/padbuildimg/assets/awk/56.png b/padbuildimg/assets/awk/56.png new file mode 100644 index 00000000..15e6365e Binary files /dev/null and b/padbuildimg/assets/awk/56.png differ diff --git a/padbuildimg/assets/awk/57.png b/padbuildimg/assets/awk/57.png new file mode 100644 index 00000000..4189441f Binary files /dev/null and b/padbuildimg/assets/awk/57.png differ diff --git a/padbuildimg/assets/awk/58.png b/padbuildimg/assets/awk/58.png new file mode 100644 index 00000000..2f6097be Binary files /dev/null and b/padbuildimg/assets/awk/58.png differ diff --git a/padbuildimg/assets/awk/59.png b/padbuildimg/assets/awk/59.png new file mode 100644 index 00000000..89a7e1d0 Binary files /dev/null and b/padbuildimg/assets/awk/59.png differ diff --git a/padbuildimg/assets/awk/6.png b/padbuildimg/assets/awk/6.png new file mode 100644 index 00000000..73255c38 Binary files /dev/null and b/padbuildimg/assets/awk/6.png differ diff --git a/padbuildimg/assets/awk/60.png b/padbuildimg/assets/awk/60.png new file mode 100644 index 00000000..4499b12e Binary files /dev/null and b/padbuildimg/assets/awk/60.png differ diff --git a/padbuildimg/assets/awk/61.png b/padbuildimg/assets/awk/61.png new file mode 100644 index 00000000..aa69abc1 Binary files /dev/null and b/padbuildimg/assets/awk/61.png differ diff --git a/padbuildimg/assets/awk/62.png b/padbuildimg/assets/awk/62.png new file mode 100644 index 00000000..4b34c928 Binary files /dev/null and b/padbuildimg/assets/awk/62.png differ diff --git a/padbuildimg/assets/awk/63.png b/padbuildimg/assets/awk/63.png new file mode 100644 index 00000000..207c1f4e Binary files /dev/null and b/padbuildimg/assets/awk/63.png differ diff --git a/padbuildimg/assets/awk/64.png b/padbuildimg/assets/awk/64.png new file mode 100644 index 00000000..3b1ebeae Binary files /dev/null and b/padbuildimg/assets/awk/64.png differ diff --git a/padbuildimg/assets/awk/65.png b/padbuildimg/assets/awk/65.png new file mode 100644 index 00000000..36a66552 Binary files /dev/null and b/padbuildimg/assets/awk/65.png differ diff --git a/padbuildimg/assets/awk/66.png b/padbuildimg/assets/awk/66.png new file mode 100644 index 00000000..fe857ed9 Binary files /dev/null and b/padbuildimg/assets/awk/66.png differ diff --git a/padbuildimg/assets/awk/67.png b/padbuildimg/assets/awk/67.png new file mode 100644 index 00000000..6389f496 Binary files /dev/null and b/padbuildimg/assets/awk/67.png differ diff --git a/padbuildimg/assets/awk/68.png b/padbuildimg/assets/awk/68.png new file mode 100644 index 00000000..e0486a63 Binary files /dev/null and b/padbuildimg/assets/awk/68.png differ diff --git a/padbuildimg/assets/awk/69.png b/padbuildimg/assets/awk/69.png new file mode 100644 index 00000000..bed09880 Binary files /dev/null and b/padbuildimg/assets/awk/69.png differ diff --git a/padbuildimg/assets/awk/7.png b/padbuildimg/assets/awk/7.png new file mode 100644 index 00000000..5c5532fb Binary files /dev/null and b/padbuildimg/assets/awk/7.png differ diff --git a/padbuildimg/assets/awk/70.png b/padbuildimg/assets/awk/70.png new file mode 100644 index 00000000..5bdc2414 Binary files /dev/null and b/padbuildimg/assets/awk/70.png differ diff --git a/padbuildimg/assets/awk/71.png b/padbuildimg/assets/awk/71.png new file mode 100644 index 00000000..cfb20624 Binary files /dev/null and b/padbuildimg/assets/awk/71.png differ diff --git a/padbuildimg/assets/awk/72.png b/padbuildimg/assets/awk/72.png new file mode 100644 index 00000000..5ce6b081 Binary files /dev/null and b/padbuildimg/assets/awk/72.png differ diff --git a/padbuildimg/assets/awk/73.png b/padbuildimg/assets/awk/73.png new file mode 100644 index 00000000..5ec3806c Binary files /dev/null and b/padbuildimg/assets/awk/73.png differ diff --git a/padbuildimg/assets/awk/74.png b/padbuildimg/assets/awk/74.png new file mode 100644 index 00000000..b1fecfc4 Binary files /dev/null and b/padbuildimg/assets/awk/74.png differ diff --git a/padbuildimg/assets/awk/75.png b/padbuildimg/assets/awk/75.png new file mode 100644 index 00000000..9c382dbd Binary files /dev/null and b/padbuildimg/assets/awk/75.png differ diff --git a/padbuildimg/assets/awk/76.png b/padbuildimg/assets/awk/76.png new file mode 100644 index 00000000..7b64ba95 Binary files /dev/null and b/padbuildimg/assets/awk/76.png differ diff --git a/padbuildimg/assets/awk/77.png b/padbuildimg/assets/awk/77.png new file mode 100644 index 00000000..9bc88aba Binary files /dev/null and b/padbuildimg/assets/awk/77.png differ diff --git a/padbuildimg/assets/awk/78.png b/padbuildimg/assets/awk/78.png new file mode 100644 index 00000000..fb9a5489 Binary files /dev/null and b/padbuildimg/assets/awk/78.png differ diff --git a/padbuildimg/assets/awk/79.png b/padbuildimg/assets/awk/79.png new file mode 100644 index 00000000..c15e36ea Binary files /dev/null and b/padbuildimg/assets/awk/79.png differ diff --git a/padbuildimg/assets/awk/8.png b/padbuildimg/assets/awk/8.png new file mode 100644 index 00000000..6ca46c5c Binary files /dev/null and b/padbuildimg/assets/awk/8.png differ diff --git a/padbuildimg/assets/awk/80.png b/padbuildimg/assets/awk/80.png new file mode 100644 index 00000000..c21a9fec Binary files /dev/null and b/padbuildimg/assets/awk/80.png differ diff --git a/padbuildimg/assets/awk/80_old.png b/padbuildimg/assets/awk/80_old.png new file mode 100644 index 00000000..078a4c83 Binary files /dev/null and b/padbuildimg/assets/awk/80_old.png differ diff --git a/padbuildimg/assets/awk/81.png b/padbuildimg/assets/awk/81.png new file mode 100644 index 00000000..6b5412ce Binary files /dev/null and b/padbuildimg/assets/awk/81.png differ diff --git a/padbuildimg/assets/awk/82.png b/padbuildimg/assets/awk/82.png new file mode 100644 index 00000000..a76bd647 Binary files /dev/null and b/padbuildimg/assets/awk/82.png differ diff --git a/padbuildimg/assets/awk/83.png b/padbuildimg/assets/awk/83.png new file mode 100644 index 00000000..243b6410 Binary files /dev/null and b/padbuildimg/assets/awk/83.png differ diff --git a/padbuildimg/assets/awk/84.png b/padbuildimg/assets/awk/84.png new file mode 100644 index 00000000..00c86d61 Binary files /dev/null and b/padbuildimg/assets/awk/84.png differ diff --git a/padbuildimg/assets/awk/85.png b/padbuildimg/assets/awk/85.png new file mode 100644 index 00000000..02c7df0d Binary files /dev/null and b/padbuildimg/assets/awk/85.png differ diff --git a/padbuildimg/assets/awk/86.png b/padbuildimg/assets/awk/86.png new file mode 100644 index 00000000..6fd9799d Binary files /dev/null and b/padbuildimg/assets/awk/86.png differ diff --git a/padbuildimg/assets/awk/87.png b/padbuildimg/assets/awk/87.png new file mode 100644 index 00000000..bfc8aa9f Binary files /dev/null and b/padbuildimg/assets/awk/87.png differ diff --git a/padbuildimg/assets/awk/89.png b/padbuildimg/assets/awk/89.png new file mode 100644 index 00000000..dec5bce4 Binary files /dev/null and b/padbuildimg/assets/awk/89.png differ diff --git a/padbuildimg/assets/awk/9.png b/padbuildimg/assets/awk/9.png new file mode 100644 index 00000000..974914b5 Binary files /dev/null and b/padbuildimg/assets/awk/9.png differ diff --git a/padbuildimg/assets/awk/90.png b/padbuildimg/assets/awk/90.png new file mode 100644 index 00000000..18295132 Binary files /dev/null and b/padbuildimg/assets/awk/90.png differ diff --git a/padbuildimg/assets/awk/91.png b/padbuildimg/assets/awk/91.png new file mode 100644 index 00000000..44ec9240 Binary files /dev/null and b/padbuildimg/assets/awk/91.png differ diff --git a/padbuildimg/assets/awk/92.png b/padbuildimg/assets/awk/92.png new file mode 100644 index 00000000..88f715e3 Binary files /dev/null and b/padbuildimg/assets/awk/92.png differ diff --git a/padbuildimg/assets/awk/93.png b/padbuildimg/assets/awk/93.png new file mode 100644 index 00000000..7a70794c Binary files /dev/null and b/padbuildimg/assets/awk/93.png differ diff --git a/padbuildimg/assets/awk/94.png b/padbuildimg/assets/awk/94.png new file mode 100644 index 00000000..3267a7f2 Binary files /dev/null and b/padbuildimg/assets/awk/94.png differ diff --git a/padbuildimg/assets/awk/95.png b/padbuildimg/assets/awk/95.png new file mode 100644 index 00000000..2bc21b2d Binary files /dev/null and b/padbuildimg/assets/awk/95.png differ diff --git a/padbuildimg/assets/awk/96.png b/padbuildimg/assets/awk/96.png new file mode 100644 index 00000000..3ae82731 Binary files /dev/null and b/padbuildimg/assets/awk/96.png differ diff --git a/padbuildimg/assets/awk/97.png b/padbuildimg/assets/awk/97.png new file mode 100644 index 00000000..ad6c65c6 Binary files /dev/null and b/padbuildimg/assets/awk/97.png differ diff --git a/padbuildimg/assets/awk/98.png b/padbuildimg/assets/awk/98.png new file mode 100644 index 00000000..dee28a17 Binary files /dev/null and b/padbuildimg/assets/awk/98.png differ diff --git a/padbuildimg/assets/awk/99.png b/padbuildimg/assets/awk/99.png new file mode 100644 index 00000000..71c3049a Binary files /dev/null and b/padbuildimg/assets/awk/99.png differ diff --git a/padbuildimg/assets/circle.png b/padbuildimg/assets/circle.png new file mode 100644 index 00000000..684bf0fe Binary files /dev/null and b/padbuildimg/assets/circle.png differ diff --git a/padbuildimg/assets/delay_buffer.png b/padbuildimg/assets/delay_buffer.png new file mode 100644 index 00000000..7b27dd11 Binary files /dev/null and b/padbuildimg/assets/delay_buffer.png differ diff --git a/padbuildimg/assets/lat/aak.png b/padbuildimg/assets/lat/aak.png new file mode 100644 index 00000000..a8fcf2d5 Binary files /dev/null and b/padbuildimg/assets/lat/aak.png differ diff --git a/padbuildimg/assets/lat/abs.png b/padbuildimg/assets/lat/abs.png new file mode 100644 index 00000000..128f59d5 Binary files /dev/null and b/padbuildimg/assets/lat/abs.png differ diff --git a/padbuildimg/assets/lat/abs120.png b/padbuildimg/assets/lat/abs120.png new file mode 100644 index 00000000..6ff71b0a Binary files /dev/null and b/padbuildimg/assets/lat/abs120.png differ diff --git a/padbuildimg/assets/lat/ah.png b/padbuildimg/assets/lat/ah.png new file mode 100644 index 00000000..1f396d71 Binary files /dev/null and b/padbuildimg/assets/lat/ah.png differ diff --git a/padbuildimg/assets/lat/all.png b/padbuildimg/assets/lat/all.png new file mode 100644 index 00000000..ac4d2461 Binary files /dev/null and b/padbuildimg/assets/lat/all.png differ diff --git a/padbuildimg/assets/lat/atk+.png b/padbuildimg/assets/lat/atk+.png new file mode 100644 index 00000000..5af04640 Binary files /dev/null and b/padbuildimg/assets/lat/atk+.png differ diff --git a/padbuildimg/assets/lat/atk.png b/padbuildimg/assets/lat/atk.png new file mode 100644 index 00000000..5445babc Binary files /dev/null and b/padbuildimg/assets/lat/atk.png differ diff --git a/padbuildimg/assets/lat/att120.png b/padbuildimg/assets/lat/att120.png new file mode 100644 index 00000000..8e76c338 Binary files /dev/null and b/padbuildimg/assets/lat/att120.png differ diff --git a/padbuildimg/assets/lat/attr.png b/padbuildimg/assets/lat/attr.png new file mode 100644 index 00000000..edc5b3e2 Binary files /dev/null and b/padbuildimg/assets/lat/attr.png differ diff --git a/padbuildimg/assets/lat/awk.png b/padbuildimg/assets/lat/awk.png new file mode 100644 index 00000000..c924e5e1 Binary files /dev/null and b/padbuildimg/assets/lat/awk.png differ diff --git a/padbuildimg/assets/lat/bak.png b/padbuildimg/assets/lat/bak.png new file mode 100644 index 00000000..26e56bd9 Binary files /dev/null and b/padbuildimg/assets/lat/bak.png differ diff --git a/padbuildimg/assets/lat/bres+.png b/padbuildimg/assets/lat/bres+.png new file mode 100644 index 00000000..41555bb0 Binary files /dev/null and b/padbuildimg/assets/lat/bres+.png differ diff --git a/padbuildimg/assets/lat/bres.png b/padbuildimg/assets/lat/bres.png new file mode 100644 index 00000000..a6c7dc24 Binary files /dev/null and b/padbuildimg/assets/lat/bres.png differ diff --git a/padbuildimg/assets/lat/cltp.png b/padbuildimg/assets/lat/cltp.png new file mode 100644 index 00000000..0a46eead Binary files /dev/null and b/padbuildimg/assets/lat/cltp.png differ diff --git a/padbuildimg/assets/lat/cltp120.png b/padbuildimg/assets/lat/cltp120.png new file mode 100644 index 00000000..7550c951 Binary files /dev/null and b/padbuildimg/assets/lat/cltp120.png differ diff --git a/padbuildimg/assets/lat/dbl.png b/padbuildimg/assets/lat/dbl.png new file mode 100644 index 00000000..12a0892f Binary files /dev/null and b/padbuildimg/assets/lat/dbl.png differ diff --git a/padbuildimg/assets/lat/dek.png b/padbuildimg/assets/lat/dek.png new file mode 100644 index 00000000..266fd054 Binary files /dev/null and b/padbuildimg/assets/lat/dek.png differ diff --git a/padbuildimg/assets/lat/dres+.png b/padbuildimg/assets/lat/dres+.png new file mode 100644 index 00000000..f4b7d53a Binary files /dev/null and b/padbuildimg/assets/lat/dres+.png differ diff --git a/padbuildimg/assets/lat/dres.png b/padbuildimg/assets/lat/dres.png new file mode 100644 index 00000000..2498ac54 Binary files /dev/null and b/padbuildimg/assets/lat/dres.png differ diff --git a/padbuildimg/assets/lat/drk.png b/padbuildimg/assets/lat/drk.png new file mode 100644 index 00000000..b21e28d4 Binary files /dev/null and b/padbuildimg/assets/lat/drk.png differ diff --git a/padbuildimg/assets/lat/emp.png b/padbuildimg/assets/lat/emp.png new file mode 100644 index 00000000..ff1cc4a9 Binary files /dev/null and b/padbuildimg/assets/lat/emp.png differ diff --git a/padbuildimg/assets/lat/enk.png b/padbuildimg/assets/lat/enk.png new file mode 100644 index 00000000..5e170d48 Binary files /dev/null and b/padbuildimg/assets/lat/enk.png differ diff --git a/padbuildimg/assets/lat/evk.png b/padbuildimg/assets/lat/evk.png new file mode 100644 index 00000000..a550c26a Binary files /dev/null and b/padbuildimg/assets/lat/evk.png differ diff --git a/padbuildimg/assets/lat/gok.png b/padbuildimg/assets/lat/gok.png new file mode 100644 index 00000000..f32f974e Binary files /dev/null and b/padbuildimg/assets/lat/gok.png differ diff --git a/padbuildimg/assets/lat/gres+.png b/padbuildimg/assets/lat/gres+.png new file mode 100644 index 00000000..64e9bd26 Binary files /dev/null and b/padbuildimg/assets/lat/gres+.png differ diff --git a/padbuildimg/assets/lat/gres.png b/padbuildimg/assets/lat/gres.png new file mode 100644 index 00000000..866b9349 Binary files /dev/null and b/padbuildimg/assets/lat/gres.png differ diff --git a/padbuildimg/assets/lat/hek.png b/padbuildimg/assets/lat/hek.png new file mode 100644 index 00000000..c66ddaf1 Binary files /dev/null and b/padbuildimg/assets/lat/hek.png differ diff --git a/padbuildimg/assets/lat/hp+.png b/padbuildimg/assets/lat/hp+.png new file mode 100644 index 00000000..f64d1a1e Binary files /dev/null and b/padbuildimg/assets/lat/hp+.png differ diff --git a/padbuildimg/assets/lat/hp.png b/padbuildimg/assets/lat/hp.png new file mode 100644 index 00000000..e10fe550 Binary files /dev/null and b/padbuildimg/assets/lat/hp.png differ diff --git a/padbuildimg/assets/lat/jsf.png b/padbuildimg/assets/lat/jsf.png new file mode 100644 index 00000000..7db08f61 Binary files /dev/null and b/padbuildimg/assets/lat/jsf.png differ diff --git a/padbuildimg/assets/lat/lock.png b/padbuildimg/assets/lat/lock.png new file mode 100644 index 00000000..f5ef795c Binary files /dev/null and b/padbuildimg/assets/lat/lock.png differ diff --git a/padbuildimg/assets/lat/lres+.png b/padbuildimg/assets/lat/lres+.png new file mode 100644 index 00000000..986b3d9d Binary files /dev/null and b/padbuildimg/assets/lat/lres+.png differ diff --git a/padbuildimg/assets/lat/lres.png b/padbuildimg/assets/lat/lres.png new file mode 100644 index 00000000..90abf6b1 Binary files /dev/null and b/padbuildimg/assets/lat/lres.png differ diff --git a/padbuildimg/assets/lat/ls.png b/padbuildimg/assets/lat/ls.png new file mode 100644 index 00000000..2db30e2c Binary files /dev/null and b/padbuildimg/assets/lat/ls.png differ diff --git a/padbuildimg/assets/lat/ls120.png b/padbuildimg/assets/lat/ls120.png new file mode 100644 index 00000000..2fac3473 Binary files /dev/null and b/padbuildimg/assets/lat/ls120.png differ diff --git a/padbuildimg/assets/lat/mak.png b/padbuildimg/assets/lat/mak.png new file mode 100644 index 00000000..50dbb83a Binary files /dev/null and b/padbuildimg/assets/lat/mak.png differ diff --git a/padbuildimg/assets/lat/nabs.png b/padbuildimg/assets/lat/nabs.png new file mode 100644 index 00000000..419fc46a Binary files /dev/null and b/padbuildimg/assets/lat/nabs.png differ diff --git a/padbuildimg/assets/lat/nabs120.png b/padbuildimg/assets/lat/nabs120.png new file mode 100644 index 00000000..f109d2b2 Binary files /dev/null and b/padbuildimg/assets/lat/nabs120.png differ diff --git a/padbuildimg/assets/lat/ncltp.png b/padbuildimg/assets/lat/ncltp.png new file mode 100644 index 00000000..67a5a9c1 Binary files /dev/null and b/padbuildimg/assets/lat/ncltp.png differ diff --git a/padbuildimg/assets/lat/ncltp120.png b/padbuildimg/assets/lat/ncltp120.png new file mode 100644 index 00000000..096a1508 Binary files /dev/null and b/padbuildimg/assets/lat/ncltp120.png differ diff --git a/padbuildimg/assets/lat/ndbl.png b/padbuildimg/assets/lat/ndbl.png new file mode 100644 index 00000000..5d298d6b Binary files /dev/null and b/padbuildimg/assets/lat/ndbl.png differ diff --git a/padbuildimg/assets/lat/nemp.png b/padbuildimg/assets/lat/nemp.png new file mode 100644 index 00000000..737e22d4 Binary files /dev/null and b/padbuildimg/assets/lat/nemp.png differ diff --git a/padbuildimg/assets/lat/nsb++.png b/padbuildimg/assets/lat/nsb++.png new file mode 100644 index 00000000..fe8e7a71 Binary files /dev/null and b/padbuildimg/assets/lat/nsb++.png differ diff --git a/padbuildimg/assets/lat/nsb++120.png b/padbuildimg/assets/lat/nsb++120.png new file mode 100644 index 00000000..3c7c3d4b Binary files /dev/null and b/padbuildimg/assets/lat/nsb++120.png differ diff --git a/padbuildimg/assets/lat/nspn.png b/padbuildimg/assets/lat/nspn.png new file mode 100644 index 00000000..2279945a Binary files /dev/null and b/padbuildimg/assets/lat/nspn.png differ diff --git a/padbuildimg/assets/lat/nspn120.png b/padbuildimg/assets/lat/nspn120.png new file mode 100644 index 00000000..68b51531 Binary files /dev/null and b/padbuildimg/assets/lat/nspn120.png differ diff --git a/padbuildimg/assets/lat/nunm.png b/padbuildimg/assets/lat/nunm.png new file mode 100644 index 00000000..d6284856 Binary files /dev/null and b/padbuildimg/assets/lat/nunm.png differ diff --git a/padbuildimg/assets/lat/nunm120.png b/padbuildimg/assets/lat/nunm120.png new file mode 100644 index 00000000..98fc1c44 Binary files /dev/null and b/padbuildimg/assets/lat/nunm120.png differ diff --git a/padbuildimg/assets/lat/phk.png b/padbuildimg/assets/lat/phk.png new file mode 100644 index 00000000..40f01bb3 Binary files /dev/null and b/padbuildimg/assets/lat/phk.png differ diff --git a/padbuildimg/assets/lat/psf.png b/padbuildimg/assets/lat/psf.png new file mode 100644 index 00000000..34365556 Binary files /dev/null and b/padbuildimg/assets/lat/psf.png differ diff --git a/padbuildimg/assets/lat/rcv+.png b/padbuildimg/assets/lat/rcv+.png new file mode 100644 index 00000000..09abc323 Binary files /dev/null and b/padbuildimg/assets/lat/rcv+.png differ diff --git a/padbuildimg/assets/lat/rcv.png b/padbuildimg/assets/lat/rcv.png new file mode 100644 index 00000000..ce5cedf2 Binary files /dev/null and b/padbuildimg/assets/lat/rcv.png differ diff --git a/padbuildimg/assets/lat/rek.png b/padbuildimg/assets/lat/rek.png new file mode 100644 index 00000000..e8938d97 Binary files /dev/null and b/padbuildimg/assets/lat/rek.png differ diff --git a/padbuildimg/assets/lat/rres+.png b/padbuildimg/assets/lat/rres+.png new file mode 100644 index 00000000..2ae763c0 Binary files /dev/null and b/padbuildimg/assets/lat/rres+.png differ diff --git a/padbuildimg/assets/lat/rres.png b/padbuildimg/assets/lat/rres.png new file mode 100644 index 00000000..6a8064a3 Binary files /dev/null and b/padbuildimg/assets/lat/rres.png differ diff --git a/padbuildimg/assets/lat/sb++.png b/padbuildimg/assets/lat/sb++.png new file mode 100644 index 00000000..8666e4d4 Binary files /dev/null and b/padbuildimg/assets/lat/sb++.png differ diff --git a/padbuildimg/assets/lat/sb++120.png b/padbuildimg/assets/lat/sb++120.png new file mode 100644 index 00000000..8719ef30 Binary files /dev/null and b/padbuildimg/assets/lat/sb++120.png differ diff --git a/padbuildimg/assets/lat/sdr.png b/padbuildimg/assets/lat/sdr.png new file mode 100644 index 00000000..753cbc9e Binary files /dev/null and b/padbuildimg/assets/lat/sdr.png differ diff --git a/padbuildimg/assets/lat/spn.png b/padbuildimg/assets/lat/spn.png new file mode 100644 index 00000000..9e6b8637 Binary files /dev/null and b/padbuildimg/assets/lat/spn.png differ diff --git a/padbuildimg/assets/lat/spn120.png b/padbuildimg/assets/lat/spn120.png new file mode 100644 index 00000000..7e96b366 Binary files /dev/null and b/padbuildimg/assets/lat/spn120.png differ diff --git a/padbuildimg/assets/lat/te+.png b/padbuildimg/assets/lat/te+.png new file mode 100644 index 00000000..bba0100b Binary files /dev/null and b/padbuildimg/assets/lat/te+.png differ diff --git a/padbuildimg/assets/lat/te.png b/padbuildimg/assets/lat/te.png new file mode 100644 index 00000000..6300faf3 Binary files /dev/null and b/padbuildimg/assets/lat/te.png differ diff --git a/padbuildimg/assets/lat/unavailable.png b/padbuildimg/assets/lat/unavailable.png new file mode 100644 index 00000000..d576f22c Binary files /dev/null and b/padbuildimg/assets/lat/unavailable.png differ diff --git a/padbuildimg/assets/lat/unm.png b/padbuildimg/assets/lat/unm.png new file mode 100644 index 00000000..49cd9266 Binary files /dev/null and b/padbuildimg/assets/lat/unm.png differ diff --git a/padbuildimg/assets/lat/unm120.png b/padbuildimg/assets/lat/unm120.png new file mode 100644 index 00000000..871133db Binary files /dev/null and b/padbuildimg/assets/lat/unm120.png differ diff --git a/padbuildimg/assets/lat/vdp.png b/padbuildimg/assets/lat/vdp.png new file mode 100644 index 00000000..5c50b17a Binary files /dev/null and b/padbuildimg/assets/lat/vdp.png differ diff --git a/padbuildimg/assets/lat/vdp120.png b/padbuildimg/assets/lat/vdp120.png new file mode 100644 index 00000000..7097dd5a Binary files /dev/null and b/padbuildimg/assets/lat/vdp120.png differ diff --git a/padbuildimg/assets/star.png b/padbuildimg/assets/star.png new file mode 100644 index 00000000..468d8ffc Binary files /dev/null and b/padbuildimg/assets/star.png differ diff --git a/padbuildimg/info.json b/padbuildimg/info.json new file mode 100644 index 00000000..ee00b5d9 --- /dev/null +++ b/padbuildimg/info.json @@ -0,0 +1,32 @@ +{ + "author": [ + "chu2.718281828459045235360287471#0110", + "The Tsubaki Bot Team" + ], + "description": "Build a PAD team image via commands.", + "end_user_data_statement": "No personal data is stored.", + "install_msg": "Use [p]helpbuildimg for help!", + "short": "PDChu cog", + "min_bot_version": "3.4.0", + "max_bot_version": "0.0.0", + "min_python_version": [ + 3, + 8, + 1 + ], + "hidden": false, + "disabled": false, + "required_cogs": { + "dbcog": "https://github.com/TsubakiBotPad/pad-cogs", + "padinfo": "https://github.com/TsubakiBotPad/pad-cogs" + }, + "requirements": [ + "tsutils", + "ply", + "Pillow", + "aiohttp" + ], + "tags": [ + "PAD" + ] +} diff --git a/padbuildimg/padbuildimg.py b/padbuildimg/padbuildimg.py new file mode 100644 index 00000000..c3228372 --- /dev/null +++ b/padbuildimg/padbuildimg.py @@ -0,0 +1,881 @@ +import csv +import math +import os +import re +import urllib +from io import BytesIO +from shutil import rmtree + +import aiohttp +import discord +from PIL import Image, ImageChops, ImageDraw, ImageFont +from ply import lex +from redbot.core import checks, commands +from redbot.core.utils.chat_formatting import box, inline +from tsutils.cog_settings import CogSettings +from tsutils.tsubaki.links import CLOUDFRONT_URL + +HELP_MSG = """ +^buildimg + +Generates an image representing a team based on a string. + +Format: + card name(assist)[latent,latent]*repeat|Stats + Card name must be first, otherwise the order does not matter + Separate each card with / + Separate each team with ; + To use / in card name, put quote around the entire team slot (e.g. "g/l medjed(g/x zela)"/...) + sdr is a special card name for dummy assists/skill delay buffers +Latent Acronyms: + Separate each latent with a , + Killers: bak(balanced), phk(physical), hek(healer), drk(dragon), gok(god), aak(attacker, dek(devil), mak(machine) + evk(evo mat), rek(redeemable), awk(awoken mat), enk(enhance) + Stats (+ for 2 slot): hp, atk, rcv, all(all stat), hp+, atk+, rcv+ + Resists (+ for 2 slot): rres, bres, gres, lres, dres, rres+, bres+, gres+, lres+, dres+ + AA4: ls(leader swap), jsf(jammer skyfall), psf(poison skyfall), attr(attribute absorb res), vdp(void damage penatration) + Awakening restricted: unm(unmatchable clear), spn(spinner clear), abs(void damage absorb) + Others: sdr, ah(autoheal) +Repeat: + *# defines number of times to repeat this particular card + e.g. whaledor(plutus)*3/whaledor(carat)*2 creates a team of 3 whaledor(plutus) followed by 2 whaledor(carat) + Latents can also be repeated, e.g. whaledor[sdr*5] for 5 sdr latents +Stats Format: + | LV### SLV## AW# SA# +H## +A## +R## +(0 or 297) + | indicates end of card name and start of stats + LV: level, 1 to 120 + SLV: skill level, 1 to 99 or MAX + AW: awakenings, 0 to 9 + SA: super awakening, 0 to 9 + +H: HP plus, 0 to 99 + +A: ATK plus, 0 to 99 + +R: RCV plus, 0 to 99 + +: total plus (+0 or +297 only) + Case insensitive, order does not matter +""" +EXAMPLE_MSG = "Examples:\n1P{}\n2P{}\n3P{}\nLatent Validation{}\nStats Validation{}".format( + box("bj(weld)lv120/baldin[gok *3](gilgamesh)/youyu(assist reeche)/mel(chocolate)/isis(koenma)/bj(rathian)"), + box("amen/dios(sdr) * 3/whaledor; mnoah(assist jack frost) *3/tengu/tengu[sdr,sdr,sdr,sdr,sdr,sdr](durandalf)"), + box("zela(assist amen) *3/base raizer * 2/zela; zela(assist amen) *4/base valeria/zela; zela * 6"), + box("eir[drk,drk,sdr]/eir[bak,bak,sdr]/eir[sdr *4, dek]/eir[sdr *8, dek]"), + box("dmeta(uruka|lv120+297slvmax)|+h33+a66+r99lv120slv15/ hmyne(buruka|lv120+297slv1)|+h99+a99+r99lv120slv15") +) + +""" +Examples: +1P: + bj(weld)lv120/baldin[gok, gok, gok](gilgamesh)/youyu(assist reeche)/mel(chocolate)/isis(koenma)/bj(rathian) +2P: + amen/dios(sdr) * 3/whaledor; mnoah(assist jack frost) *3/tengu/tengu[sdr,sdr,sdr,sdr,sdr,sdr](durandalf) +3P: + zela(assist amen) *3/base raizer * 2/zela; zela(assist amen) *4/base valeria/zela; zela * 6 +Latent Validation: + eir[drk,drk,sdr]/eir[bak,bak,sdr] +Stats Validation: + dmeta(uruka|lv120+297slvmax)|+h33+a66+r99lv120slv15/ hmyne(buruka|lv120+297slv1)|+h99+a99+r99lv120slv15 + +""" + +MAX_LATENTS = 8 +LATENTS_MAP = { + # NOT APPLICABLE + 1: 'emp', + 2: 'nemp', + + # ONE SLOT + 101: 'hp', + 102: 'atk', + 103: 'rcv', + 104: 'rres', + 105: 'bres', + 106: 'gres', + 107: 'lres', + 108: 'dres', + 109: 'ah', + 110: 'sdr', + 111: 'te', + + # TWO SLOTS + 201: 'bak', + 202: 'phk', + 203: 'hek', + 204: 'drk', + 205: 'gok', + 206: 'aak', + 207: 'dek', + 208: 'mak', + 209: 'evk', + 210: 'rek', + 211: 'awk', + 212: 'enk', + 213: 'all', + 214: 'hp+', + 215: 'atk+', + 216: 'rcv+', + 217: 'rres+', + 218: 'bres+', + 219: 'gres+', + 220: 'lres+', + 221: 'dres+', + 222: 'te+', + + # SIX SLOTS + 601: 'psf', + 602: 'jsf', + 603: 'ls', + 604: 'vdp', + 605: 'attr', + 606: 'unm', + 607: 'spn', + 608: 'abs', +} +AWO_RES_LATENT_TO_AWO_MAP = { + 606: 27, + 607: 20, + 608: 62, +} +LATENTS_MAP.update({-k: 'n' + LATENTS_MAP[k] for k in AWO_RES_LATENT_TO_AWO_MAP}) +REVERSE_LATENTS_MAP = {v: k for k, v in LATENTS_MAP.items()} +TYPE_TO_KILLERS_MAP = { + 'God': [207], # devil + 'Devil': [205], # god + 'Machine': [205, 201], # god balanced + 'Dragon': [208, 203], # machine healer + 'Physical': [208, 203], # machine healer + 'Attacker': [207, 202], # devil physical + 'Healer': [204, 206], # dragon attacker + 'Balanced': [201, 202, 203, 204, 205, 206, 207, 208] +} + +AWK_CIRCLE = 'circle' +AWK_STAR = 'star' +DELAY_BUFFER = 'delay_buffer' +REMOTE_ASSET_URL = 'https://raw.githubusercontent.com/TsubakiBotPad/padbot-cogs/master/padbuildimg/assets/' +REMOTE_AWK_URL = CLOUDFRONT_URL + '/media/awakenings/{0:03d}.png' + + +# REMOTE_LAT_URL = 'https://pad.protic.site/wp-content/uploads/pad-latents/' + +class DictWithAttributeAccess(dict): + def __getattr__(self, key): + return self[key] + + def __setattr__(self, key, value): + self[key] = value + + +class PadBuildImgSettings(CogSettings): + def make_default_build_img_params(self): + build_img_params = DictWithAttributeAccess({ + 'ASSETS_DIR': './assets/', + 'PORTRAIT_DIR': CLOUDFRONT_URL + '/media/icons/{monster_id:05d}.png', + # 'OUTPUT_DIR': './data/padbuildimg/output/', + 'PORTRAIT_WIDTH': 100, + 'PADDING': 10, + 'LATENTS_WIDTH': 25, + 'FONT_NAME': './assets/OpenSans-ExtraBold.ttf' + }) + if not os.path.exists(build_img_params.ASSETS_DIR): + os.mkdir(build_img_params.ASSETS_DIR) + # if not os.path.exists(build_img_params.OUTPUT_DIR): + # os.mkdir(build_img_params.OUTPUT_DIR) + return build_img_params + + async def red_get_data_for_user(self, *, user_id): + """Get a user's personal data.""" + data = "No data is stored for user with ID {}.\n".format(user_id) + return {"user_data.txt": BytesIO(data.encode())} + + async def red_delete_data_for_user(self, *, requester, user_id): + """Delete a user's personal data. + + No personal data is stored in this cog. + """ + return + + def buildImgParams(self): + if 'build_img_params' not in self.bot_settings: + self.bot_settings['build_img_params'] = self.make_default_build_img_params() + self.save_settings() + return DictWithAttributeAccess(self.bot_settings['build_img_params']) + + def setBuildImgParamsByKey(self, key, value): + if 'build_img_params' not in self.bot_settings: + self.bot_settings['build_img_params'] = self.make_default_build_img_params() + if key in self.bot_settings['build_img_params']: + self.bot_settings['build_img_params'][key] = value + self.save_settings() + + async def downloadAssets(self, source, target): + async with aiohttp.ClientSession() as session: + async with session.get(source) as resp: + data = await resp.read() + with open(target, "wb") as f: + f.write(data) + + async def downloadAllAssets(self, awk_ids): + params = self.buildImgParams() + if os.path.exists(params.ASSETS_DIR): + rmtree(params.ASSETS_DIR) + os.mkdir(params.ASSETS_DIR) + os.mkdir(params.ASSETS_DIR + 'lat/') + os.mkdir(params.ASSETS_DIR + 'awk/') + for lat in LATENTS_MAP.values(): + await self.downloadAssets(REMOTE_ASSET_URL + 'lat/' + lat + '.png', + params.ASSETS_DIR + 'lat/' + lat + '.png') + for awk in awk_ids: + await self.downloadAssets(REMOTE_AWK_URL.format(awk), params.ASSETS_DIR + 'awk/' + str(awk) + '.png') + await self.downloadAssets(REMOTE_ASSET_URL + AWK_CIRCLE + '.png', params.ASSETS_DIR + AWK_CIRCLE + '.png') + await self.downloadAssets(REMOTE_ASSET_URL + AWK_STAR + '.png', params.ASSETS_DIR + AWK_STAR + '.png') + await self.downloadAssets(REMOTE_ASSET_URL + DELAY_BUFFER + '.png', params.ASSETS_DIR + DELAY_BUFFER + '.png') + font_name = os.path.basename(params.FONT_NAME) + await self.downloadAssets(REMOTE_ASSET_URL + font_name, params.ASSETS_DIR + font_name) + + def dmOnly(self, server_id): + if 'dm_only' not in self.bot_settings: + self.bot_settings['dm_only'] = [] + self.save_settings() + return server_id in self.bot_settings['dm_only'] + + def toggleDmOnly(self, server_id): + if 'dm_only' not in self.bot_settings: + self.bot_settings['dm_only'] = [] + else: + if server_id in self.bot_settings['dm_only']: + self.bot_settings['dm_only'].remove(server_id) + else: + self.bot_settings['dm_only'].append(server_id) + self.save_settings() + + +def lstripalpha(s): + while s and not s[0].isdigit(): + s = s[1:] + return s + + +class PaDTeamLexer: + tokens = [ + 'ID', + 'ASSIST', + 'LATENT', + 'STATS', + 'SPACES', + 'LV', + 'SLV', + 'AWAKE', + 'SUPER', + 'P_HP', + 'P_ATK', + 'P_RCV', + 'P_ALL', + 'REPEAT', + ] + + def t_ID(self, t): + r'^.+?(?=[\(\|\[\*])|^(?!.*[\(\|\[\*].*).+' + # first word before ( or [ or | or * entire word if those characters are not in string + t.value = t.value.strip() + return t + + def t_ASSIST(self, t): + r'\(.*?\)' + # words in () + t.value = t.value.strip('()') + return t + + def t_LATENT(self, t): + r'\[.+?\]' + # words in [] + t.value = [lat.strip().lower() for lat in t.value.strip('[]').split(',')] + for v in t.value.copy(): + if '*' not in v: + continue + tmp = [lat.strip() for lat in v.split('*')] + if len(tmp[0]) == 1 and tmp[0].isdigit(): + count = int(tmp[0]) + latent = tmp[1] + elif len(tmp[1]) == 1 and tmp[1].isdigit(): + count = int(tmp[1]) + latent = tmp[0] + else: + continue + idx = t.value.index(v) + t.value.remove(v) + for i in range(count): + t.value.insert(idx, latent) + t.value = t.value[0:MAX_LATENTS] + t.value = [REVERSE_LATENTS_MAP[lat] for lat in t.value if lat in REVERSE_LATENTS_MAP] + return t + + def t_STATS(self, t): + r'\|' + pass + + def t_SPACES(self, t): + r'\s' + # spaces must be checked after ID + pass + + def t_LV(self, t): + r'[lL][vV][lL]?\s?\d{1,3}' + # LV followed by 1~3 digit number + t.value = int(lstripalpha(t.value[2:])) + return t + + def t_SLV(self, t): + r'[sS][lL][vV]\s?(\d{1,2}|[mM][aA][xX])' + # SL followed by 1~2 digit number or max + t.value = t.value[3:] + if t.value.isdigit(): + t.value = int(t.value) + else: + t.value = 99 + return t + + def t_AWAKE(self, t): + r'[aA][wW]\s?\d' + # AW followed by 1 digit number + t.value = int(t.value[2:]) + return t + + def t_SUPER(self, t): + r'[sS][aA]\s?\d' + # SA followed by 1 digit number + t.value = int(t.value[2:]) + return t + + def t_P_ALL(self, t): + r'\+\s?\d{1,3}' + # + followed by 0 or 297 + t.value = min(int(t.value[1:]), 297) + return t + + def t_P_HP(self, t): + r'\+[hH]\s?\d{1,2}' + # +H followed by 1~2 digit number + t.value = int(t.value[2:]) + return t + + def t_P_ATK(self, t): + r'\+[aA]\s?\d{1,2}' + # +A followed by 1~2 digit number + t.value = int(t.value[2:]) + return t + + def t_P_RCV(self, t): + r'\+[rR]\s?\d{1,2}' + # +R followed by 1~2 digit number + t.value = int(t.value[2:]) + return t + + def t_REPEAT(self, t): + r'\*\s?\d' + # * followed by a number + t.value = min(int(t.value[1:]), MAX_LATENTS) + return t + + t_ignore = '\t\n' + + def t_error(self, t): + raise commands.UserFeedbackCheckFailure( + "Parse Error: Unknown text '{}' at position {}".format(t.value, t.lexpos)) + + def __init__(self): + self.lexer = None + + def build(self, **kwargs): + # pass debug=1 to enable verbose output + self.lexer = lex.lex(module=self) + return self.lexer + + +def validate_latents(card_dict, card, ass_card): + latents = card_dict['LATENT'] + card_types = [t.name for t in card.types] + awos = {a.awoken_skill_id for a in card.awakenings[:-card.superawakening_count]} + if card_dict['SUPER'] and card.superawakening_count: + awos.add(card.awakenings[-card.superawakening_count + card_dict['SUPER'] - 1].awoken_skill_id) + if ass_card and ass_card.is_equip: + awos |= {a.awoken_skill_id for a in ass_card.awakenings} + if latents is None: + return None + for idx, l in enumerate(latents): + if 200 < l < 209: + if not any([l in TYPE_TO_KILLERS_MAP[t] for t in card_types if t is not None]): + latents[idx] = None + if l in AWO_RES_LATENT_TO_AWO_MAP and AWO_RES_LATENT_TO_AWO_MAP[l] not in awos: + latents[idx] = -latents[idx] + latents = [lat for lat in latents if lat is not None] + return latents if len(latents) > 0 else None + + +def outline_text(draw, x, y, font, text_color, text, thickness=1): + shadow_color = 'black' + draw.text((x - thickness, y - thickness), text, font=font, fill=shadow_color) + draw.text((x + thickness, y - thickness), text, font=font, fill=shadow_color) + draw.text((x - thickness, y + thickness), text, font=font, fill=shadow_color) + draw.text((x + thickness, y + thickness), text, font=font, fill=shadow_color) + draw.text((x, y), text, font=font, fill=text_color) + + +def trim(im): + bg = Image.new(im.mode, im.size, (255, 255, 255, 0)) + diff = ImageChops.difference(im, bg) + diff = ImageChops.add(diff, diff, 2.0, -100) + bbox = diff.getbbox() + if bbox: + return im.crop(bbox) + + +def text_center_pad(font_size, line_height): + return math.floor((line_height - font_size) / 3) + + +def idx_to_xy(idx): + return idx // 2, - (idx % 2) + + +class PadBuildImageGenerator(object): + def __init__(self, params, ctx, build_name='pad_build'): + self.params = params + self.ctx = ctx + self.dbcog = ctx.bot.get_cog("DBCog") + self.lexer = PaDTeamLexer().build() + self.build = { + 'NAME': build_name, + 'TEAM': [], + 'INSTRUCTION': None + } + self.build_img = None + + async def process_build(self, input_str): + team_strings = [row for row in csv.reader(re.split('[;\n]', input_str), delimiter='/') if len(row) > 0] + if len(team_strings) > 3: + team_strings = team_strings[0:3] + for team in team_strings: + team_sublist = [] + for slot in team: + try: + team_sublist.extend(await self.process_card(slot)) + except Exception as ex: + self.build['TEAM'] = [] + raise ex + self.build['TEAM'].append(team_sublist) + + async def process_card(self, card_str, is_assist=False): + if not is_assist: + result_card = { + '+ATK': 99, + '+HP': 99, + '+RCV': 99, + 'AWAKE': 9, + 'SUPER': 0, + 'MAX_AWAKE': 9, + 'GOLD_STAR': True, + 'ID': 0, + 'LATENT': None, + 'LV': 99, + 'SLV': 0, + 'ON_COLOR': True, + 'EXTRA_SLOTS': False + } + else: + result_card = { + '+ATK': 0, + '+HP': 0, + '+RCV': 0, + 'AWAKE': 0, + 'SUPER': 0, + 'MAX_AWAKE': 0, + 'GOLD_STAR': True, + 'ID': 0, + 'LATENT': None, + 'LV': 1, + 'SLV': 0, + 'MAX_SLV': 0, + 'ON_COLOR': False, + 'EXTRA_SLOTS': False + } + if len(card_str) == 0: + if is_assist: + result_card['ID'] = DELAY_BUFFER + return result_card, None + else: + return [] + self.lexer.input(card_str) + assist_str = None + ass_card = None + card = None + repeat = 1 + for tok in iter(self.lexer.token, None): + if tok.type == 'ASSIST': + assist_str = tok.value + ass_card = await self.dbcog.find_monster(tok.value, self.ctx.author.id) + if ass_card is None: + raise commands.UserFeedbackCheckFailure(f'Lookup Error: Could not find a monster to match:' + f' {tok.value}.') + elif tok.type == 'REPEAT': + repeat = min(tok.value, MAX_LATENTS) + elif tok.type == 'ID': + if tok.value.lower() == 'sdr': + result_card['ID'] = DELAY_BUFFER + card = DELAY_BUFFER + else: + card = await self.dbcog.find_monster(tok.value, self.ctx.author.id) + if card is None: + raise commands.UserFeedbackCheckFailure(f'Lookup Error: Could not find a monster to match:' + f' {tok.value}.') + if not card.is_inheritable: + if is_assist: + return None, None + else: + result_card['GOLD_STAR'] = False + result_card[ + 'MNO'] = card.monster_no_na if card.monster_no_na != card.monster_id else card.monster_no_jp + result_card['ID'] = card.monster_id + elif tok.type == 'P_ALL': + if tok.value >= 297: + result_card['+HP'] = 99 + result_card['+ATK'] = 99 + result_card['+RCV'] = 99 + else: + result_card['+HP'] = 0 + result_card['+ATK'] = 0 + result_card['+RCV'] = 0 + elif tok.type != 'STATS': + result_card[tok.type.replace('P_', '+')] = tok.value + card_att = None + if card is None: + return [] + elif card != DELAY_BUFFER: + result_card['LATENT'] = validate_latents( + result_card, + card, + ass_card + ) + result_card['LV'] = min( + result_card['LV'], + 120 if card.limit_mult is not None and card.limit_mult > 1 else card.level + ) + if card.active_skill: + result_card['MAX_SLV'] = card.active_skill.cooldown_turns_max - card.active_skill.cooldown_turns_min + 1 + else: + result_card['MAX_SLV'] = 0 + result_card['MAX_AWAKE'] = len(card.awakenings) - card.superawakening_count + if is_assist: + result_card['MAX_AWAKE'] = result_card['MAX_AWAKE'] if result_card['AWAKE'] > 0 else 0 + result_card['AWAKE'] = result_card['MAX_AWAKE'] + result_card['SUPER'] = 0 + else: + result_card['SUPER'] = min(result_card['SUPER'], card.superawakening_count) + if result_card['SUPER'] > 0: + super_awakes = [x.awoken_skill_id for x in card.awakenings[-card.superawakening_count:]] + result_card['SUPER'] = super_awakes[result_card['SUPER'] - 1] + result_card['LV'] = max(100, result_card['LV']) + card_att = card.attr1 + if is_assist: + return result_card, card_att + else: + parsed_cards = [result_card] + if isinstance(assist_str, str): + assist_card, assist_att = await self.process_card(assist_str, is_assist=True) + if card_att is not None and assist_att is not None: + assist_card['ON_COLOR'] = card_att == assist_att + parsed_cards.append(assist_card) + else: + parsed_cards.append(None) + parsed_cards = parsed_cards * repeat + return parsed_cards + + def combine_latents(self, card): + latents = card['LATENT'] + if not latents: + return False + if len(latents) > MAX_LATENTS: + latents = latents[0:MAX_LATENTS] + latents_bar = Image.new('RGBA', + (self.params.PORTRAIT_WIDTH, self.params.LATENTS_WIDTH * 2), + (255, 255, 255, 0)) + x_offset = 0 + y_offset = 0 + row_count = 0 + one_slot, two_slot, six_slot = [], [], [] + for lat in latents: + if 100 <= lat < 200: + one_slot.append(lat) + elif 200 <= lat < 300: + two_slot.append(lat) + elif 600 <= lat < 700: + six_slot.append(lat) + six_slot.append(1) + elif -700 < lat <= -600: + six_slot.append(lat) + six_slot.append(2) + sorted_latents = [] + if len(one_slot) > len(two_slot): + sorted_latents.extend(six_slot) + sorted_latents.extend(one_slot) + sorted_latents.extend(two_slot) + else: + sorted_latents.extend(six_slot) + sorted_latents.extend(two_slot) + sorted_latents.extend(one_slot) + last_height = 0 + for lat in sorted_latents: + latent_icon = Image.open(self.params.ASSETS_DIR + 'lat/' + LATENTS_MAP[lat] + '.png') + if x_offset + latent_icon.size[0] > self.params.PORTRAIT_WIDTH: + row_count += 1 + x_offset = 0 + y_offset += last_height + if row_count >= MAX_LATENTS // 4 and x_offset + latent_icon.size[0] >= self.params.LATENTS_WIDTH * ( + MAX_LATENTS % 4): + break + latents_bar.paste(latent_icon, (x_offset, y_offset)) + last_height = latent_icon.size[1] + x_offset += latent_icon.size[0] + + return latents_bar + + def combine_portrait(self, card, show_stats=True, show_supers=False): + if card['ID'] == DELAY_BUFFER: + return Image.open(self.params.ASSETS_DIR + DELAY_BUFFER + '.png') + if 'http' in self.params.PORTRAIT_DIR: + portrait = Image.open(urllib.request.urlopen(self.params.PORTRAIT_DIR.format(monster_id=card['ID']))) + else: + portrait = Image.open(self.params.PORTRAIT_DIR.format(monster_id=card['ID'])) + draw = ImageDraw.Draw(portrait) + slv_offset = 80 + if show_stats: + # + eggsinclude_instructions + sum_plus = card['+HP'] + card['+ATK'] + card['+RCV'] + if 0 < sum_plus: + if sum_plus < 297: + font = ImageFont.truetype(self.params.FONT_NAME, 14) + outline_text(draw, 5, 2, font, 'yellow', '+{:d} HP'.format(card['+HP'])) + outline_text(draw, 5, 14, font, 'yellow', '+{:d} ATK'.format(card['+ATK'])) + outline_text(draw, 5, 26, font, 'yellow', '+{:d} RCV'.format(card['+RCV'])) + else: + font = ImageFont.truetype(self.params.FONT_NAME, 18) + outline_text(draw, 5, 0, font, 'yellow', '+297') + # level + if card['LV'] > 0: + outline_text(draw, 5, 75, ImageFont.truetype(self.params.FONT_NAME, 18), + 'white', 'Lv.{:d}'.format(card['LV'])) + slv_offset = 65 + # skill level + if card['MAX_SLV'] > 0 and card['SLV'] > 0: + slv_txt = 'SLv.max' if card['SLV'] >= card['MAX_SLV'] else 'SLv.{:d}'.format(card['SLV']) + outline_text(draw, 5, slv_offset, + ImageFont.truetype(self.params.FONT_NAME, 12), 'pink', slv_txt) + # ID + outline_text(draw, 67, 82, ImageFont.truetype(self.params.FONT_NAME, 12), 'lightblue', str(card['MNO'])) + del draw + if card['MAX_AWAKE'] > 0: + # awakening + if card['AWAKE'] >= card['MAX_AWAKE']: + awake = Image.open(self.params.ASSETS_DIR + AWK_STAR + '.png') + else: + awake = Image.open(self.params.ASSETS_DIR + AWK_CIRCLE + '.png') + draw = ImageDraw.Draw(awake) + draw.text((8, -2), str(card['AWAKE']), + font=ImageFont.truetype(self.params.FONT_NAME, 18), fill='yellow') + del draw + portrait.paste(awake, (self.params.PORTRAIT_WIDTH - awake.size[0] - 5, 5), awake) + awake.close() + if show_supers and card['SUPER'] > 0: + # SA + awake = Image.open(self.params.ASSETS_DIR + 'awk/' + str(card['SUPER']) + '.png') + portrait.paste(awake, + (self.params.PORTRAIT_WIDTH - awake.size[0] - 5, + (self.params.PORTRAIT_WIDTH - awake.size[0]) // 2), + awake) + awake.close() + return portrait + + def generate_build_image(self, include_instructions=False): + if self.build is None: + return + team_size = max([len(x) for x in self.build['TEAM']]) + p_w = self.params.PORTRAIT_WIDTH * math.ceil(team_size / 2) + \ + self.params.PADDING * math.ceil(team_size / 10) + p_h = (self.params.PORTRAIT_WIDTH + self.params.LATENTS_WIDTH + self.params.PADDING) * \ + 2 * len(self.build['TEAM']) + include_instructions &= self.build['INSTRUCTION'] is not None + if include_instructions: + p_h += len(self.build['INSTRUCTION']) * (self.params.PORTRAIT_WIDTH // 2 + self.params.PADDING) + self.build_img = Image.new('RGBA', + (p_w, p_h), + (255, 255, 255, 0)) + y_offset = 0 + for team in self.build['TEAM']: + has_assist = any([card is not None for idx, card in enumerate(team) if idx % 2 == 1]) + has_latents = any([card['LATENT'] is not None for idx, card in enumerate(team) + if idx % 2 == 0 and card is not None]) + if has_assist: + y_offset += self.params.PORTRAIT_WIDTH + for idx, card in enumerate(team): + if idx > 11 or idx > 9 and len(self.build['TEAM']) % 2 == 0: + break + if card is not None: + x, y = idx_to_xy(idx) + portrait = self.combine_portrait( + card, + show_stats=card['ON_COLOR'], + show_supers=len(self.build['TEAM']) != 2) + if portrait is None: + continue + x_offset = self.params.PADDING * math.ceil(x / 4) + self.build_img.paste( + portrait, + (x_offset + x * self.params.PORTRAIT_WIDTH, + y_offset + y * self.params.PORTRAIT_WIDTH)) + if has_latents and idx % 2 == 0 and card['LATENT'] is not None: + latents = self.combine_latents(card) + self.build_img.paste( + latents, + (x_offset + x * self.params.PORTRAIT_WIDTH, + y_offset + (y + 1) * self.params.PORTRAIT_WIDTH)) + latents.close() + portrait.close() + y_offset += self.params.PORTRAIT_WIDTH + self.params.PADDING * 2 + if has_latents: + y_offset += self.params.LATENTS_WIDTH * 2 + + if include_instructions: + y_offset -= self.params.PADDING * 2 + draw = ImageDraw.Draw(self.build_img) + font = ImageFont.truetype(self.params.FONT_NAME, 24) + text_padding = text_center_pad(25, self.params.PORTRAIT_WIDTH // 2) + for step in self.build['INSTRUCTION']: + x_offset = self.params.PADDING + outline_text(draw, x_offset, y_offset + text_padding, + font, 'white', 'F{:d} - P{:d} '.format(step['FLOOR'], step['PLAYER'] + 1)) + x_offset += self.params.PORTRAIT_WIDTH + if step['ACTIVE'] is not None: + actives_used = [self.build['TEAM'][idx][ids] + for idx, side in enumerate(step['ACTIVE']) + for ids in side] + for card in actives_used: + if 'http' in self.params.PORTRAIT_DIR: + p_small = Image.open( + urllib.request.urlopen(self.params.PORTRAIT_DIR.format(monster_id=card['ID']))).resize( + (self.params.PORTRAIT_WIDTH // 2, self.params.PORTRAIT_WIDTH // 2), Image.LINEAR) + else: + p_small = Image.open(self.params.PORTRAIT_DIR.format(monster_id=card['ID'])).resize( + (self.params.PORTRAIT_WIDTH // 2, self.params.PORTRAIT_WIDTH // 2), Image.LINEAR) + self.build_img.paste(p_small, (x_offset, y_offset)) + x_offset += self.params.PORTRAIT_WIDTH // 2 + x_offset += self.params.PADDING + outline_text(draw, x_offset, y_offset + text_padding, font, 'white', step['ACTION']) + y_offset += self.params.PORTRAIT_WIDTH // 2 + del draw + + self.build_img = trim(self.build_img) + + +class PadBuildImage(commands.Cog): + """PAD Build Image Generator.""" + + def __init__(self, bot): + self.bot = bot + self.settings = PadBuildImgSettings("padbuildimg") + + async def get_dbcog(self): + dbcog = self.bot.get_cog("DBCog") + if dbcog is None: + raise ValueError("DBCog cog is not loaded") + await dbcog.wait_until_ready() + return dbcog + + @commands.command() + async def helpbuildimg(self, ctx): + """Help info for the buildimage command.""" + await ctx.author.send(box(HELP_MSG)) + if checks.admin_or_permissions(manage_guild=True): + await ctx.author.send(box('For Server Admins: Output location can be changed between current channel and ' + 'direct messages via ^togglebuildimgoutput')) + await ctx.author.send(EXAMPLE_MSG) + + @commands.command(aliases=['buildimg', 'pdchu']) + async def padbuildimg(self, ctx, *, build_str: str): + """Create a build image based on input. + Use ^helpbuildimg for more info. + """ + + async with ctx.typing(): + params = self.settings.buildImgParams() + try: + pbg = PadBuildImageGenerator(params, ctx) + await pbg.process_build(build_str) + pbg.generate_build_image() + except commands.UserFeedbackCheckFailure as ex: + await ctx.send(box(str(ex) + '\nSee ^helpbuildimg for syntax')) + return -1 + + if pbg.build_img is not None: + with BytesIO() as build_io: + pbg.build_img.save(build_io, format='PNG') + build_io.seek(0) + if ctx.guild and self.settings.dmOnly(ctx.guild.id): + try: + await ctx.author.send(file=discord.File(build_io, 'pad_build.png')) + await ctx.send(inline('Sent build to {}'.format(ctx.author))) + except discord.errors.Forbidden as ex: + await ctx.send(inline('Failed to send build to {}'.format(ctx.author))) + else: + try: + await ctx.send(file=discord.File(build_io, 'pad_build.png')) + except discord.errors.Forbidden as ex: + await ctx.send(inline("Failed to send build. (Insufficient Permisisons)")) + else: + await ctx.send(box('Invalid build, see ^helpbuildimg')) + return 0 + + @commands.command() + @checks.is_owner() + async def configbuildimg(self, ctx, param_key: str, param_value: str): + """ + Configure PadBuildImageGenerator parameters: + ASSETS_DIR - directory for storing assets (use ^refreshassets to update) + PORTRAIT_DIR - path pattern to where portraits are stored, {monster_id} must be present + PORTRAIT_WIDTH - width of portraits, default 100 + PADDING - padding between various things, default 10 + LATENTS_WIDTH - width of 1 slot latent, default 25 + FONT_NAME - path to font + """ + if param_key in ['ASSETS_DIR', 'PORTRAIT_DIR', 'PORTRAIT_WIDTH', 'PADDING', 'LATENTS_WIDTH', 'FONT_NAME']: + if param_key in ['PORTRAIT_WIDTH', 'PADDING', 'LATENTS_WIDTH']: + param_value = int(param_value) + if param_key in ['ASSETS_DIR'] and param_value[-1] not in ['/', '\\']: + param_value += '/' + self.settings.setBuildImgParamsByKey(param_key, param_value) + await ctx.send(box('Set {} to {}'.format(param_key, param_value))) + else: + await ctx.send(box('Invaalid parameter {}'.format(param_key))) + + @commands.command() + @checks.is_owner() + async def refreshassets(self, ctx): + """ + Refresh assets folder + """ + async with ctx.typing(): + await ctx.send('Downloading assets to {}'.format(self.settings.buildImgParams().ASSETS_DIR)) + dbcog = await self.get_dbcog() + awk_ids = dbcog.database.awoken_skill_map.keys() + await self.settings.downloadAllAssets(awk_ids) + await ctx.tick() + + @commands.command() + @commands.guild_only() + @checks.admin_or_permissions(manage_guild=True) + async def togglebuildimgoutput(self, ctx): + """ + Toggles between sending result to server vs sending result to direct message + """ + self.settings.toggleDmOnly(ctx.guild.id) + if self.settings.dmOnly(ctx.guild.id): + await ctx.send('Response mode set to direct message') + else: + await ctx.send('Response mode set to current channel') diff --git a/padbuilds/__init__.py b/padbuilds/__init__.py index ea10d782..a092012c 100644 --- a/padbuilds/__init__.py +++ b/padbuilds/__init__.py @@ -1,5 +1,7 @@ -from .padbuilds import * +from .padbuilds import PadBuilds +__red_end_user_data_statement__ = "No personal data is stored." -def setup(bot): - bot.add_cog(PadBuilds(bot)) + +async def setup(bot): + bot.add_cog(PadBuilds(bot)) if not __import__('asyncio').iscoroutinefunction(bot.add_cog) else await bot.add_cog(PadBuilds(bot)) diff --git a/padbuilds/info.json b/padbuilds/info.json new file mode 100644 index 00000000..04a71dbd --- /dev/null +++ b/padbuilds/info.json @@ -0,0 +1,25 @@ +{ + "author": [ + "The Tsubaki Bot Team" + ], + "description": "View user-set builds for various dungeons.", + "end_user_data_statement": "No personal data is stored.", + "install_msg": "Use [p]builds to get started.", + "short": "Builds to help you through dungeons", + "min_bot_version": "3.4.0", + "max_bot_version": "0.0.0", + "min_python_version": [ + 3, + 8, + 1 + ], + "hidden": false, + "disabled": false, + "required_cogs": {}, + "requirements": [ + "tsutils" + ], + "tags": [ + "PAD" + ] +} diff --git a/padbuilds/padbuilds.py b/padbuilds/padbuilds.py index 4580cf31..4c3f97f6 100644 --- a/padbuilds/padbuilds.py +++ b/padbuilds/padbuilds.py @@ -1,11 +1,10 @@ -import re - import discord +import re +from io import BytesIO from redbot.core import checks from redbot.core import commands -from redbot.core.utils.chat_formatting import pagify, box - -from rpadutils import CogSettings +from redbot.core.utils.chat_formatting import box, pagify +from tsutils.cog_settings import CogSettings class PadBuilds(commands.Cog): @@ -18,7 +17,19 @@ def __init__(self, bot, *args, **kwargs): self.bot = bot self.c_commands = PadBuildSettings("padbuilds") - @commands.group(aliases=["build"]) + async def red_get_data_for_user(self, *, user_id): + """Get a user's personal data.""" + data = "No data is stored for user with ID {}.\n".format(user_id) + return {"user_data.txt": BytesIO(data.encode())} + + async def red_delete_data_for_user(self, *, requester, user_id): + """Delete a user's personal data. + + No personal data is stored in this cog. + """ + return + + @commands.group(aliases=["build", "padbuilds"]) @commands.guild_only() async def builds(self, ctx): """PAD Builds management""" @@ -149,7 +160,7 @@ async def on_message(self, message): await message.channel.send(cmd) def format_cc(self, command, message): - results = re.findall(r"\{([^}]+)\}", command) + results = re.findall(r"{([^}]+)}", command) for result in results: param = self.transform_parameter(result, message) command = command.replace("{" + result + "}", param) diff --git a/padevents/__init__.py b/padevents/__init__.py index a73d6a75..4fed2fd8 100644 --- a/padevents/__init__.py +++ b/padevents/__init__.py @@ -1,10 +1,11 @@ -import warnings +import json +from pathlib import Path -from .padevents import * +from .padevents import PadEvents +with open(Path(__file__).parent / "info.json") as file: + __red_end_user_data_statement__ = json.load(file)['end_user_data_statement'] -def setup(bot): - n = PadEvents(bot) - bot.add_cog(n) - bot.loop.create_task(n.reload_padevents()) - bot.loop.create_task(n.check_started()) + +async def setup(bot): + bot.add_cog(PadEvents(bot)) if not __import__('asyncio').iscoroutinefunction(bot.add_cog) else await bot.add_cog(PadEvents(bot)) diff --git a/padevents/autoevent_mixin.py b/padevents/autoevent_mixin.py new file mode 100644 index 00000000..d1f836f9 --- /dev/null +++ b/padevents/autoevent_mixin.py @@ -0,0 +1,444 @@ +import logging +import re +from typing import Any, Dict, Optional, Set, TypeVar, Union + +import discord +import time +from redbot.core import Config, checks, commands +from redbot.core.bot import Red +from redbot.core.utils.chat_formatting import box, humanize_timedelta, inline +from tsutils.cogs.donations import is_donor +from tsutils.emoji import NO_EMOJI, YES_EMOJI +from tsutils.enums import Server +from tsutils.errors import ClientInlineTextException +from tsutils.formatting import rmdiacritics +from tsutils.user_interaction import get_user_confirmation, send_cancellation_message, send_confirmation_message + +from padevents.events import Event + +logger = logging.getLogger('red.padbot-cogs.padevents') +T = TypeVar("T") + + +def user_is_donor(ctx, only_patron=False): + if ctx.author.id in ctx.bot.owner_ids: + return True + donationcog = ctx.bot.get_cog("Donations") + if not donationcog: + return False + return donationcog.is_donor(ctx, only_patron) + + +class AutoEvent: + bot: Red + config: Config + events: Set[Event] + started_events: Set[Event] + + async def do_autoevents(self): + events = filter(lambda e: e.key not in self.started_events, self.events) + for event in events: + for gid, data in (await self.config.all_guilds()).items(): + guild = self.bot.get_guild(gid) + if guild is None: + continue + for key, aep in data.get('pingroles', {}).items(): + if event.start_from_now_sec() > aep['offset'] * 60 \ + or str((key, event.key, gid)) in await self.config.sent(): + continue + if not self.event_matches_autoevent(event, aep): + continue + + async with self.config.sent() as sent: + sent[str((key, event.key, gid))] = time.time() + + channel = guild.get_channel(aep['channel']) + if channel is None: + continue + role = guild.get_role(aep['role']) + ment = role.mention if role else "" + offsetstr = "now" + if aep['offset']: + offsetstr = f"" + try: + timestr = humanize_timedelta(timedelta=event.close_datetime - event.open_datetime) + await channel.send(f"{event.clean_dungeon_name} starts {offsetstr}!" + f" It will be active for {timestr}. {ment}", + allowed_mentions=discord.AllowedMentions(roles=True)) + except Exception: + logger.exception("Failed to send AEP in channel {}".format(channel.id)) + + for uid, data in (await self.config.all_users()).items(): + user = self.bot.get_user(uid) + if user is None: + continue + for aed in data.get('dmevents', []): + if event.start_from_now_sec() > aed['offset'] * 60 \ + or str((aed['key'], event.key, uid)) in await self.config.sent(): + continue + if not self.event_matches_autoevent(event, aed): + continue + async with self.config.sent() as sent: + sent[str((aed['key'], event.key, uid))] = time.time() + offsetstr = "now" + if aed['offset']: + offsetstr = f"" + timestr = humanize_timedelta(timedelta=event.close_datetime - event.open_datetime) + try: + await user.send(f"{event.clean_dungeon_name} starts {offsetstr}!" + f" It will be active for {timestr}.") + except Exception: + logger.exception("Failed to send AED to user {}".format(user.id)) + + def event_matches_autoevent(self, event: Event, autoevent: Dict[str, Any]): + if not autoevent.get('enabled', True) \ + or event.server != autoevent['server'] \ + or autoevent['searchstr'] is None: + return False + if not autoevent.get('include3p', True) and event.clean_dungeon_name.startswith("Multiplayer"): + return False + if autoevent.get('regex'): + return re.search(autoevent['searchstr'], event.clean_dungeon_name) \ + or re.search(autoevent['searchstr'], event.dungeon_name) + else: + return autoevent['searchstr'].lower() in event.clean_dungeon_name.lower() \ + or autoevent['searchstr'].lower() in event.dungeon_name.lower() + + @commands.group(aliases=['aep']) + @commands.guild_only() + @checks.mod_or_permissions(manage_guild=True) + async def autoeventping(self, ctx): + """Auto Event Pings""" + + @autoeventping.command(name="add", ignore_extra=False) + async def aep_add(self, ctx, key, server: Server = None, searchstr=None, + role: Optional[discord.Role] = None, + channel: discord.TextChannel = None): + """Add a new autoeventping. + Use `[p]aep set` with your chosen key to finish or update config. + `channel` defaults to the current channel. + + Usage: + `[p]aep add diamondra` + `[p]aep add pluspoints NA "star treasure" @red @blue @green #channel` + `[p]aep add wallace KR "wallace" @eventping #channel` + """ + + if " " in key: + await ctx.send("Multi-word keys are not allowed.") + return + + if server: + server = server.value + + default = { + 'role': None, + 'channel': (channel or ctx.channel).id, + 'server': server or 'NA', + 'searchstr': searchstr and rmdiacritics(searchstr), + 'regex': False, + 'enabled': bool(role or channel), + 'offset': 0, + } + + if role is not None: + default['role'] = role.id + + async with self.config.guild(ctx.guild).pingroles() as pingroles: + pingroles[key] = default + + await ctx.send("New AEP created:") + await self.aep_show(ctx, key) + + @autoeventping.command(name="remove", aliases=['rm', 'delete']) + async def aep_remove(self, ctx, key): + """Remove an autoeventping""" + async with self.config.guild(ctx.guild).pingroles() as pingroles: + if key not in pingroles: + await ctx.send("That key does not exist.") + return + del pingroles[key] + await ctx.tick() + + @autoeventping.command(name="show") + async def aep_show(self, ctx, key): + """Show specifics of an autoeventping""" + pingroles = await self.config.guild(ctx.guild).pingroles() + if key not in pingroles: + await ctx.send("That key does not exist.") + return + role = (ctx.guild.get_role(pingroles[key]['role']).mention + if ctx.guild.get_role(pingroles[key]['role']) is not None + else "No Role") + channel = (ctx.bot.get_channel(pingroles[key]['channel']).mention + if ctx.bot.get_channel(pingroles[key]['channel']) is not None + else "No Channel") + pr = (f"Key: `{key}`\n" + f"\tStatus: {YES_EMOJI + ' enabled' if pingroles[key]['enabled'] else NO_EMOJI + ' disabled'}\n" + f"\tSearch string: `{pingroles[key]['searchstr']}` {'(regex search)' * pingroles[key]['regex']}\n" + f"\tServer: {pingroles[key]['server']}\n" + f"\tRed: {role} (In {channel})\n" + f"\tOffset: `{pingroles[key]['offset']} minutes`") + await ctx.send(pr) + + @autoeventping.command(name="list") + async def aep_list(self, ctx): + """List all autoeventpings""" + pingroles = await self.config.guild(ctx.guild).pingroles() + if len(pingroles) == 0: + await ctx.send('You have no auto event pings configured currently!') + return + await ctx.send(box('\n'.join(pingroles))) + + @autoeventping.group(name="set") + async def aep_set(self, ctx): + """Sets specific parts of an autoeventping""" + + async def aepset(self, ctx, key: str, k: str, v: Any): + async with self.config.guild(ctx.guild).pingroles() as pingroles: + if key not in pingroles: + await ctx.send("That key does not exist.") + return + pingroles[key][k] = v + + async def aepget(self, ctx, key: str): + pingroles = await self.config.guild(ctx.guild).pingroles() + if key not in pingroles: + raise ClientInlineTextException(f"Key `{key}` does not exist. Available keys are:" + f" {', '.join(map(inline, pingroles))}") + return pingroles[key] + + @aep_set.command(name="channel") + async def aep_s_channel(self, ctx, key, channel: discord.TextChannel): + """Sets channel to ping""" + await self.aepset(ctx, key, 'channel', channel.id) + await ctx.tick() + + @aep_set.command(name="roles") + async def aep_s_roles(self, ctx, key, role: discord.Role): + """Sets roles to ping""" + await self.aepset(ctx, key, 'role', role.id) + await ctx.tick() + + @aep_set.command(name="server") + async def aep_s_server(self, ctx, key, server: Server): + """Sets which server to listen to events in""" + server = server.value + await self.aepset(ctx, key, 'server', server) + await ctx.tick() + + @aep_set.command(name="searchstr") + async def aep_s_searchstr(self, ctx, key, *, searchstr): + """Sets what string is tested against event name""" + searchstr = rmdiacritics(searchstr).strip('"') + if (await self.aepget(ctx, key))['regex']: + try: + re.compile(searchstr) + except re.error: + await ctx.send("Invalid regex searchstr. (`{}`)".format(searchstr)) + return + await self.aepset(ctx, key, 'searchstr', searchstr) + await ctx.tick() + + @aep_set.command(name="regex") + async def aep_s_regex(self, ctx, key, regex: bool): + """Sets whether searchstr is calculated via regex""" + if regex: + try: + re.compile((await self.aepget(ctx, key))['searchstr']) + except re.error: + await ctx.send("Invalid regex searchstr. (`{}`)".format((await self.aepget(ctx, key))['searchstr'])) + return + await self.aepset(ctx, key, 'regex', regex) + await ctx.tick() + + @aep_set.command(name="enabled", aliases=['enable']) + async def aep_s_enabled(self, ctx, key, enabled: bool = True): + """Sets whether or not ping is enabled""" + await self.aepset(ctx, key, 'enabled', enabled) + await ctx.tick() + + @aep_set.command(name="disabled", aliases=['disable']) + async def aep_s_disabled(self, ctx, key, disabled: bool = True): + """Sets whether or not ping is disabled""" + await self.aepset(ctx, key, 'enabled', not disabled) + await ctx.tick() + + @aep_set.command(name="offset") + async def aep_s_offset(self, ctx, key, offset: int): + """Sets how many minutes before event should ping happen""" + if offset < 0: + await ctx.send("Offset cannot be negative.") + return + await self.aepset(ctx, key, 'offset', offset) + await ctx.tick() + + @autoeventping.command(name="channelmessage") + async def aep_channelmessage(self, ctx, channel: Optional[discord.TextChannel], enable: bool): + """Whether to show daily AEP summaries""" + await self.config.channel(channel or ctx.channel).do_aep_post.set(enable) + await ctx.tick() + + @commands.group(aliases=['aed']) + async def autoeventdm(self, ctx): + """Auto Event DMs""" + + @autoeventdm.command(name="add") + async def aed_add(self, ctx, server: Server, searchstr, time_offset: int = 0): + """Add a new autoeventdm""" + server = server.value + + if time_offset and not user_is_donor(ctx): + await ctx.send("You must be a donor to set a time offset!") + return + if time_offset < 0: + await ctx.send("Offset cannot be negative") + return + + default = { + 'key': time.time(), + 'server': server, + 'searchstr': searchstr, + 'include3p': True, + 'offset': time_offset, + } + + index = await self._do_aed_add(ctx, default) + await ctx.send("New AED created:") + await self.aed_show(ctx, index) + + @autoeventdm.command(name="remove", aliases=['rm', 'delete']) + async def aed_remove(self, ctx, index): + """Remove an autoeventdm""" + corrected_index = await self._get_and_validate_aed_index(ctx, index) + if corrected_index is None: + return + async with self.config.user(ctx.author).dmevents() as dmevents: + if not await get_user_confirmation(ctx, "Are you sure you want to delete autoeventdm {}. `{}`?" + .format(corrected_index + 1, dmevents[corrected_index]['searchstr'])): + return + dmevents.pop(corrected_index) + await ctx.tick() + + @autoeventdm.command(name="show") + async def aed_show(self, ctx, index): + """Show specifics of an autoeventdm""" + dmevents = await self.config.user(ctx.author).dmevents() + corrected_index = await self._get_and_validate_aed_index(ctx, index) + if corrected_index is None: + return + if dmevents[corrected_index].get('include3p') is None: + # case of legacy configs + dmevents[corrected_index]['include3p'] = True + ret = (f"Lookup number: `{corrected_index + 1}`\n" + f"\tSearch string: `{dmevents[corrected_index]['searchstr']}`\n" + f"\t3P: {'included' if dmevents[corrected_index]['include3p'] else 'excluded'}\n" + f"\tServer: {dmevents[corrected_index]['server']}\n" + f"\tOffset (Donor Only): `{dmevents[corrected_index]['offset']} minutes`") + await ctx.send(ret) + + @autoeventdm.command(name="list") + async def aed_list(self, ctx): + """List current autoeventdms""" + dmevents = await self.config.user(ctx.author).dmevents() + msg = "\n".join("{}. {}".format(c, aed['searchstr']) for c, aed in enumerate(dmevents, 1)) + if msg: + await ctx.send(box(msg)) + else: + await ctx.send("You have no autoeventdms set up.") + + @autoeventdm.command(name="purge") + async def aed_purge(self, ctx): + """Remove an autoeventdm""" + if not await self.config.user(ctx.author).dmevents(): + await ctx.send("You don't have any autoeventdms.") + return + if not await get_user_confirmation(ctx, "Are you sure you want to purge your autoeventdms?"): + return + await self.config.user(ctx.author).dmevents.set([]) + await ctx.tick() + + @autoeventdm.group(name="edit", aliases=["set"]) + async def aed_e(self, ctx): + """Edit a property of the autoeventdm""" + + async def _do_aed_add(self, ctx, item): + """Add autoeventdm and return its index""" + async with self.config.user(ctx.author).dmevents() as dmevents: + dmevents.append(item) + return len(dmevents) + + @is_donor() + @aed_e.command(name="offset") + async def aed_e_offset(self, ctx, index, offset: int): + """(DONOR ONLY) Set time offset to an AED to allow you to prepare for a dungeon""" + if offset < 0: + await send_cancellation_message(ctx, "Offset cannot be negative") + return + corrected_index = await self._get_and_validate_aed_index(ctx, index) + if corrected_index is None: + return + async with self.config.user(ctx.author).dmevents() as dmevents: + if not await get_user_confirmation(ctx, "Modify the offset for {}. `{}` to {} minutes?" + .format(corrected_index + 1, dmevents[corrected_index]['searchstr'], offset)): + return + dmevents[corrected_index]['offset'] = offset + await ctx.tick() + + @aed_e.command(name="searchstr") + async def aed_e_searchstr(self, ctx, index, *, searchstr): + """Set search string of an autoeventdm""" + searchstr = searchstr.strip('"') + corrected_index = await self._get_and_validate_aed_index(ctx, index) + if corrected_index is None: + return + async with self.config.user(ctx.author).dmevents() as dmevents: + if not await get_user_confirmation(ctx, "Modify the search string for {}. `{}` to `{}`?" + .format(corrected_index + 1, dmevents[corrected_index]['searchstr'], searchstr)): + return + dmevents[corrected_index]['searchstr'] = searchstr + await ctx.tick() + + @aed_e.command(name="toggle3p") + async def aed_e_toggle3p(self, ctx, index): + """Include/exclude 3-player dungeons in an autoeventdm""" + corrected_index = await self._get_and_validate_aed_index(ctx, index) + if corrected_index is None: + return + async with self.config.user(ctx.author).dmevents() as dmevents: + event = dmevents[corrected_index]['searchstr'] + if dmevents[corrected_index].get('include3p') is None: + # case of legacy configs + dmevents[corrected_index]['include3p'] = True + if dmevents[corrected_index]['include3p']: + dmevents[corrected_index]['include3p'] = False + await send_confirmation_message(ctx, "I will **exclude** 3P dungeons for {}. `{}`" + .format(corrected_index + 1, event)) + return + dmevents[corrected_index]['include3p'] = True + await send_confirmation_message(ctx, "I will **include** 3P dungeons for {}. `{}`" + .format(corrected_index + 1, event)) + return + + async def _get_and_validate_aed_index(self, ctx, user_index: Union[str, int]): + """Returns the corrected index for the autoeventdm of interest, based on Python list indexing""" + async with self.config.user(ctx.author).dmevents() as dmevents: + if isinstance(user_index, int) or user_index.isdigit(): + user_index = int(user_index) + if not 0 < user_index <= len(dmevents): + await send_cancellation_message(ctx, "That isn't a valid numerical index.") + return None + else: + str_index = None + for i in range(1, len(dmevents) + 1, 1): + if dmevents[i - 1]['searchstr'] == user_index: + str_index = i + break + user_index = str_index + if user_index is None: + await send_cancellation_message(ctx, f"That string did not match any existing patterns." + f" Printing all of your Auto Event DMs (you can also" + f" see this with `{ctx.prefix}autoeventdm list`):") + await self.aed_list(ctx) + return None + return user_index - 1 diff --git a/padevents/enums.py b/padevents/enums.py new file mode 100644 index 00000000..d271f7da --- /dev/null +++ b/padevents/enums.py @@ -0,0 +1,20 @@ +from enum import Enum, auto + + +class EventLength(Enum): + limited = auto() + daily = auto() + weekly = auto() + special = auto() + + +class DungeonType(Enum): + Unknown = -1 + Normal = 0 + Special = 1 + Technical = 2 + SoloSpecial = 3 + Tournament = 4 + ThreePlayer = 7 + Story = 9 + EightPlayer = 10 diff --git a/padevents/events.py b/padevents/events.py new file mode 100644 index 00000000..91c962e4 --- /dev/null +++ b/padevents/events.py @@ -0,0 +1,128 @@ +import datetime +from datetime import timedelta +from typing import Callable, Collection, TYPE_CHECKING + +import pytz +from redbot.core.utils.chat_formatting import inline +from tsutils.formatting import normalize_server_name +from tsutils.time import JP_TIMEZONE, KR_TIMEZONE, NA_TIMEZONE + +from padevents.enums import DungeonType, EventLength + +if TYPE_CHECKING: + from dbcog.models.scheduled_event_model import ScheduledEventModel + +SUPPORTED_SERVERS = ["JP", "NA", "KR"] +SERVER_TIMEZONES = { + "JP": JP_TIMEZONE, + "NA": NA_TIMEZONE, + "KR": KR_TIMEZONE, +} + + +class Event: + def __init__(self, scheduled_event: "ScheduledEventModel"): + self.key = scheduled_event.event_id + self.server = SUPPORTED_SERVERS[scheduled_event.server_id] + self.open_datetime: datetime.datetime = scheduled_event.open_datetime + self.close_datetime: datetime.datetime = scheduled_event.close_datetime + self.dungeon = scheduled_event.dungeon + self.dungeon_name = self.dungeon.name_en if self.dungeon else 'unknown_dungeon' + + self.clean_dungeon_name = self.dungeon.clean_name_en if self.dungeon else 'unknown_dungeon' + + self.dungeon_type = DungeonType(self.dungeon.dungeon_type) if self.dungeon else DungeonType.Unknown + + @property + def event_length(self) -> EventLength: + # This is a little off. I don't know how exact these things are. + length = self.close_datetime - self.open_datetime + if length > timedelta(days=8): + return EventLength.special + if length > timedelta(days=2): + return EventLength.weekly + if length > timedelta(hours=20): + return EventLength.daily + return EventLength.limited + + def start_from_now_sec(self) -> float: + now = datetime.datetime.now(pytz.utc) + return (self.open_datetime - now).total_seconds() + + def end_from_now_sec(self) -> float: + now = datetime.datetime.now(pytz.utc) + return (self.close_datetime - now).total_seconds() + + def is_started(self): + """True if past the open time for the event.""" + return self.start_from_now_sec() <= 0 + + def is_finished(self): + """True if past the close time for the event.""" + return self.end_from_now_sec() <= 0 + + def start_from_now_discord(self, output_type: str) -> str: + return f"" + + def end_from_now_discord(self, output_type: str) -> str: + return f"" + + def end_from_now_full_min(self) -> str: + days, sec = divmod(self.end_from_now_sec(), 86400) + hours, sec = divmod(sec, 3600) + minutes, sec = divmod(sec, 60) + + if days > 0: + return '{:2}d {:2}h'.format(int(days), int(hours)) + elif hours > 0: + return '{:2}h {:2}m'.format(int(hours), int(minutes)) + else: + return '{:2}m'.format(int(minutes)) + + def to_partial_event(self, pe, output_type: str): + ret = inline(self.clean_dungeon_name.ljust(24) + " - ") + if self.is_started(): + return ret + self.end_from_now_discord(output_type) + else: + return ret + self.start_from_now_discord(output_type) + + def __repr__(self): + return f"Event<{self.clean_dungeon_name} ({self.server})>" + + +class EventList: + def __init__(self, event_list: Collection[Event]): + self.event_list = event_list + + def with_func(self, func: Callable[[Event], bool]) -> "EventList": + return EventList(list(filter(func, self.event_list))) + + def with_server(self, *servers): + servers = {normalize_server_name(s) for s in servers} + return self.with_func(lambda e: e.server in servers) + + def with_type(self, *event_types): + return self.with_func(lambda e: e.event_type in event_types) + + def with_length(self, *event_lengths): + return self.with_func(lambda e: e.event_length in event_lengths) + + def with_dungeon_type(self, *dungeon_types): + return self.with_func(lambda e: e.dungeon_type in dungeon_types) + + def pending_only(self): + return self.with_func(lambda e: not e.is_started()) + + def active_only(self): + return self.with_func(lambda e: e.is_started() and not e.is_finished()) + + def today_only(self, server: str): + server_timezone = SERVER_TIMEZONES[normalize_server_name(server)] + today = datetime.datetime.now(server_timezone).date() + return self.with_func(lambda e: e.open_datetime.astimezone(server_timezone).date() == today) + + def __iter__(self): + return iter(self.event_list) + + def __bool__(self): + return bool(self.event_list) diff --git a/padevents/info.json b/padevents/info.json new file mode 100644 index 00000000..2308c86f --- /dev/null +++ b/padevents/info.json @@ -0,0 +1,29 @@ +{ + "author": [ + "The Tsubaki Bot Team" + ], + "description": "Stay up to date on PAD dungeons.", + "end_user_data_statement": "Auto Event DMs are stored with your user ID.", + "install_msg": "Use [p]padevents to get started.", + "short": "PAD Event tracker", + "min_bot_version": "3.4.0", + "max_bot_version": "0.0.0", + "min_python_version": [ + 3, + 8, + 1 + ], + "hidden": false, + "disabled": false, + "required_cogs": { + "dbcog": "https://github.com/TsubakiBotPad/pad-cogs" + }, + "requirements": [ + "tsutils>=4.0.7", + "pytz", + "prettytable" + ], + "tags": [ + "PAD" + ] +} diff --git a/padevents/padevents.py b/padevents/padevents.py index 86dadb87..feae1bd3 100644 --- a/padevents/padevents.py +++ b/padevents/padevents.py @@ -1,728 +1,407 @@ -import traceback import asyncio -import re import datetime -import pytz +import logging from collections import defaultdict +from contextlib import suppress from datetime import timedelta -from enum import Enum +from io import BytesIO +from typing import Any, NoReturn, Optional -import prettytable import discord -from redbot.core import checks -from redbot.core import commands -from redbot.core.bot import Red -from redbot.core.utils.chat_formatting import inline, box, pagify +import prettytable +import pytz +import time +from redbot.core import Config, checks, commands +from redbot.core.utils.chat_formatting import box, pagify +from tsutils.enums import Server +from tsutils.formatting import normalize_server_name +from tsutils.helper_classes import DummyObject +from tsutils.helper_functions import conditional_iterator, repeating_timer + +from padevents.autoevent_mixin import AutoEvent +from padevents.enums import DungeonType, EventLength +from padevents.events import Event, EventList, SERVER_TIMEZONES -from dadguide import dadguide -from rpadutils import CogSettings +logger = logging.getLogger('red.padbot-cogs.padevents') -SUPPORTED_SERVERS = ["JP", "NA", "KR", "UK"] +SUPPORTED_SERVERS = ["JP", "NA", "KR"] -class PadEvents(commands.Cog): - def __init__(self, bot: Red, *args, **kwargs): +class PadEvents(commands.Cog, AutoEvent): + """Pad Event Tracker""" + + def __init__(self, bot, *args, **kwargs): super().__init__(*args, **kwargs) self.bot = bot - self.settings = PadEventSettings("padevents") + self.config = Config.get_conf(self, identifier=940373775) + self.config.register_global(sent={}, last_daychange=None) + self.config.register_guild(pingroles={}) + self.config.register_channel(guerrilla_servers=[], daily_servers=[], do_aep_post=True) + self.config.register_user(dmevents=[]) # Load event data - self.events = list() + self.events = set() self.started_events = set() - self.fake_uid = -999 + self.fake_uid = -time.time() + + self._event_loop = bot.loop.create_task(self.reload_padevents()) + self._refresh_loop = bot.loop.create_task(self.do_loop()) + self._daily_event_loop = bot.loop.create_task(self.show_daily_info()) + + async def red_get_data_for_user(self, *, user_id): + """Get a user's personal data.""" + aeds = await self.config.user_from_id(user_id).dmevents() + if aeds: + data = f"You have {len(aeds)} AEDs stored. Use" \ + f" {(await self.bot.get_valid_prefixes())[0]}aed list to see what they are.\n" + else: + data = f"No data is stored for user with ID {user_id}." + return {"user_data.txt": BytesIO(data.encode())} + + async def red_delete_data_for_user(self, *, requester, user_id): + """Delete a user's personal data.""" + await self.config.user_from_id(user_id).clear() def cog_unload(self): # Manually nulling out database because the GC for cogs seems to be pretty shitty - self.events = list() + self.events = set() self.started_events = set() + self._event_loop.cancel() + self._refresh_loop.cancel() + self._daily_event_loop.cancel() - async def reload_padevents(self): + async def reload_padevents(self) -> NoReturn: await self.bot.wait_until_ready() - while self == self.bot.get_cog('PadEvents'): - try: - await self.refresh_data() - print('Done refreshing PadEvents') - except Exception as ex: - print("reload padevents loop caught exception " + str(ex)) - traceback.print_exc() + with suppress(asyncio.CancelledError): + async for _ in repeating_timer(60 * 60): + try: + await self.refresh_data() + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Error in loop:") + + async def do_loop(self) -> NoReturn: + await self.bot.wait_until_ready() + with suppress(asyncio.CancelledError): + async for _ in repeating_timer(10): + try: + await self.do_autoevents() + await self.do_eventloop() + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Error in loop:") + + async def show_daily_info(self) -> NoReturn: + async def is_day_change(): + curserver = self.get_most_recent_day_change() + oldserver = self.config.last_daychange + if curserver != await oldserver(): + await oldserver.set(curserver) + return curserver - await asyncio.sleep(60 * 60 * 1) + await self.bot.wait_until_ready() + with suppress(asyncio.CancelledError): + async for server in conditional_iterator(is_day_change, poll_interval=10): + try: + await self.do_daily_post(server) + await self.do_autoevent_summary(server) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Error in loop:") async def refresh_data(self): - dg_cog = self.bot.get_cog('Dadguide') - await dg_cog.wait_until_ready() - scheduled_events = dg_cog.database.get_all_events() + dbcog: Any = self.bot.get_cog('DBCog') + await dbcog.wait_until_ready() + scheduled_events = dbcog.database.get_all_events() - new_events = [] + self.events = set() for se in scheduled_events: try: - new_events.append(Event(se)) + self.events.add(Event(se)) except Exception as ex: - print(ex) - - self.events = new_events - self.started_events = {ev.key for ev in new_events if ev.is_started()} - - async def check_started(self): - await self.bot.wait_until_ready() - while self == self.bot.get_cog('PadEvents'): - try: - events = filter(lambda e: e.is_started() and not e.key in self.started_events, self.events) - - daily_refresh_servers = set() - for e in events: - self.started_events.add(e.key) - if e.event_type in [EventType.Guerrilla, EventType.GuerrillaNew, EventType.SpecialWeek]: - for gr in list(self.settings.listGuerrillaReg()): - if SUPPORTED_SERVERS[e.server_id] == gr['server']: - try: - message = box("Server " + SUPPORTED_SERVERS[e.server_id] + ", group " + - e.groupLongName() + " : " + e.name_and_modifier) - channel = self.bot.get_channel(int(gr['channel_id'])) - - try: - role_name = '{}_group_{}'.format( - e.server, e.groupLongName()) - role = get_role(channel.guild.roles, role_name) - if role and role.mentionable: - message = "{} `: {} is starting`".format( - role.mention, e.name_and_modifier) - except: - pass # do nothing if role is missing - - await channel.send(message) - except Exception as ex: - # deregister gr - traceback.print_exc() - # self.settings.removeGuerrillaReg(gr['channel_id'], gr['server']) - print( - "caught exception while sending guerrilla msg" + str(ex)) - - else: - if not e.dungeon_type in [DungeonType.Normal]: - daily_refresh_servers.add(e.server) - - for server in daily_refresh_servers: - msg = self.makeActiveText(server) - for daily_registration in list(self.settings.listDailyReg()): - try: - if server == daily_registration['server']: - await self.pageOutput(ctx, msg, channel_id=daily_registration['channel_id']) - except Exception as ex: - traceback.print_exc() - # self.settings.removeDailyReg( - # daily_registration['channel_id'], daily_registration['server']) - print("caught exception while sending daily msg " + str(ex)) - - except Exception as ex: - traceback.print_exc() - print("caught exception while checking guerrillas " + str(ex)) - - try: - await asyncio.sleep(10) - except Exception as ex: - traceback.print_exc() - print("check event loop caught exception " + str(ex)) - raise ex - print("done check_started") - - @commands.group() + logger.exception("Refresh error:") + + self.started_events = {ev.key for ev in self.events if ev.is_started()} + async with self.config.sent() as seen: + for key, value in [*seen.items()]: + if value < time.time() - 60 * 60: + del seen[key] + + async def do_eventloop(self): + events = filter(lambda e: e.is_started() and e.key not in self.started_events, self.events) + daily_refresh_servers = set() + for event in events: + self.started_events.add(event.key) + if event.event_length != EventLength.limited: + continue + for cid, data in (await self.config.all_channels()).items(): + if (channel := self.bot.get_channel(cid)) is None \ + or event.server not in data['guerrilla_servers']: + continue + message = box(f"Server {event.server}: {event.clean_dungeon_name}") + with suppress(discord.Forbidden): + await channel.send(message, allowed_mentions=discord.AllowedMentions(roles=True)) + + async def do_daily_post(self, server): + ongoing_daily_msg = self.make_active_ongoing_daily_text(server) + limited_msg = self.make_active_limited_text(server) + for cid, data in (await self.config.all_channels()).items(): + if (channel := self.bot.get_channel(cid)) is None \ + or server not in data['daily_servers']: + continue + for page in pagify(ongoing_daily_msg, delims=['\n\n']): + with suppress(discord.Forbidden): + await channel.send(box(page)) + if limited_msg: + for page in pagify(limited_msg): + with suppress(discord.Forbidden): + await channel.send(page) + + async def do_autoevent_summary(self, server): + events = EventList(self.events).with_server(server).today_only('NA') + for gid, data in (await self.config.all_guilds()).items(): + if (guild := self.bot.get_guild(gid)) is None: + continue + channels = defaultdict(list) + for key, aep in data.get('pingroles', {}).items(): + if aep['channel'] is not None: + channels[aep['channel']].append(aep) + for cid, aeps in channels.items(): + if (channel := self.bot.get_channel(cid)) is None: + continue + if not await self.config.channel(channel).do_aep_post(): + continue + aepevents = events.with_func(lambda e: any(self.event_matches_autoevent(e, ae) for ae in aeps)) + if not aepevents: + continue + msg = self.make_full_guerrilla_output('AEP Event', aepevents, server) + for page in pagify(msg): + with suppress(discord.Forbidden): + await channel.send(page) + + @commands.group(aliases=['pde']) @checks.mod_or_permissions(manage_guild=True) async def padevents(self, ctx): """PAD event tracking""" @padevents.command() @checks.is_owner() - async def testevent(self, ctx, server): - # FIXME: (it's VERY borked) - server = normalizeServer(server) - if server not in SUPPORTED_SERVERS: - await ctx.send("Unsupported server, pick one of NA, KR, JP") - return - - te = dadguide.DgScheduledEvent([], None) - te.server = server - - te.dungeon_code = 1 - te.event_type = EventType.Guerrilla - te.event_seq = 0 - self.fake_uid = self.fake_uid - 1 - te.key = self.fake_uid - te.group = 'F' - - te.open_datetime = datetime.datetime.now(pytz.utc) - te.close_datetime = te.open_datetime + timedelta(minutes=1) - te.dungeon_name = 'fake_dungeon_name' - te.event_modifier = 'fake_event_modifier' - self.events.append(Event(te)) - - await ctx.send("Fake event injected.") + async def testevent(self, ctx, server: Server, seconds: int = 0): + server = server.value + + dbcog: Any = self.bot.get_cog('DBCog') + await dbcog.wait_until_ready() + # TODO: Don't use this awful importing hack + dg_module = __import__('.'.join(dbcog.__module__.split('.')[:-1]) + ".models.scheduled_event_model") + timestamp = int((datetime.datetime.now(pytz.utc) + timedelta(seconds=seconds)).timestamp()) + self.fake_uid -= 1 + + te = dg_module.models.scheduled_event_model.ScheduledEventModel( + event_id=self.fake_uid, + server_id=SUPPORTED_SERVERS.index(server), + event_type_id=-1, + start_timestamp=timestamp, + end_timestamp=timestamp + 60, + dungeon_model=DummyObject( + name_en='fake_dungeon_name', + clean_name_en='fake_dungeon_name', + dungeon_type=DungeonType.ThreePlayer, + dungeon_id=1, + ) + ) + self.events.add(Event(te)) + await ctx.tick() @padevents.command() @commands.guild_only() @checks.mod_or_permissions(manage_guild=True) - async def addchannel(self, ctx, server): - server = normalizeServer(server) - if server not in SUPPORTED_SERVERS: - await ctx.send("Unsupported server, pick one of NA, KR, JP") - return - - channel_id = ctx.channel.id - if self.settings.checkGuerrillaReg(channel_id, server): - await ctx.send("Channel already active.") - return - - self.settings.addGuerrillaReg(channel_id, server) + async def addchannel(self, ctx, channel: Optional[discord.TextChannel], server: Server): + server = server.value + + async with self.config.channel(channel or ctx.channel).guerrilla_servers() as guerillas: + if server in guerillas: + return await ctx.send("Channel already active.") + guerillas.append(server) await ctx.send("Channel now active.") @padevents.command() @commands.guild_only() @checks.mod_or_permissions(manage_guild=True) - async def rmchannel(self, ctx, server): - server = normalizeServer(server) - if server not in SUPPORTED_SERVERS: - await ctx.send("Unsupported server, pick one of NA, ~~KR~~, JP") - return + async def rmchannel(self, ctx, channel: Optional[discord.TextChannel], server: Server): + server = server.value - channel_id = ctx.channel.id - if not self.settings.checkGuerrillaReg(channel_id, server): - await ctx.send("Channel is not active.") - return - - self.settings.removeGuerrillaReg(channel_id, server) - await ctx.send("Channel deactivated.") + async with self.config.channel(channel or ctx.channel).guerrilla_servers() as guerillas: + if server not in guerillas: + return await ctx.send("Channel already inactive.") + guerillas.remove(server) + await ctx.send("Channel now inactive.") @padevents.command() @commands.guild_only() @checks.mod_or_permissions(manage_guild=True) - async def addchanneldaily(self, ctx, server): - server = normalizeServer(server) - if server not in SUPPORTED_SERVERS: - await ctx.send("Unsupported server, pick one of NA, ~~KR~~, JP") - return - - channel_id = ctx.channel.id - if self.settings.checkDailyReg(channel_id, server): - await ctx.send("Channel already active.") - return - - self.settings.addDailyReg(channel_id, server) + async def addchanneldaily(self, ctx, channel: Optional[discord.TextChannel], server: Server): + server = server.value + + async with self.config.channel(channel or ctx.channel).daily_servers() as dailies: + if server in dailies: + return await ctx.send("Channel already active.") + dailies.append(server) await ctx.send("Channel now active.") @padevents.command() @commands.guild_only() @checks.mod_or_permissions(manage_guild=True) - async def rmchanneldaily(self, ctx, server): - server = normalizeServer(server) - if server not in SUPPORTED_SERVERS: - await ctx.send("Unsupported server, pick one of NA, ~~KR~~, JP") - return + async def rmchanneldaily(self, ctx, channel: Optional[discord.TextChannel], server: Server): + server = server.value - channel_id = ctx.channel.id - if not self.settings.checkDailyReg(channel_id, server): - await ctx.send("Channel is not active.") - return + async with self.config.channel(channel or ctx.channel).daily_servers() as dailies: + if server not in dailies: + return await ctx.send("Channel already inactive.") + dailies.remove(server) + await ctx.send("Channel now inactive.") - self.settings.removeDailyReg(channel_id, server) - await ctx.send("Channel deactivated.") - - @padevents.command(name="listchannels") + @padevents.command() @checks.mod_or_permissions(manage_guild=True) - async def _listchannel(self, ctx): - msg = 'Following daily channels are registered:\n' - msg += self.makeChannelList(self.settings.listDailyReg()) - msg += "\n" - msg += 'Following guerilla channels are registered:\n' - msg += self.makeChannelList(self.settings.listGuerrillaReg()) - await self.pageOutput(ctx, msg) - - def makeChannelList(self, reg_list): - msg = "" - for cr in reg_list: - reg_channel_id = cr['channel_id'] - channel = self.bot.get_channel(int(reg_channel_id)) - channel_name = channel.name if channel else 'Unknown(' + reg_channel_id + ')' - server_name = channel.guild.name if channel else 'Unknown server' - msg += " " + cr['server'] + " : " + server_name + '(' + channel_name + ')\n' + async def active(self, ctx, server: Server): + server = server.value + + ongoing_daily_msg = self.make_active_ongoing_daily_text(server) + for page in pagify(ongoing_daily_msg, delims=['\n\n']): + await ctx.send(box(page)) + limited_msg = self.make_active_limited_text(server) + if limited_msg: + for page in pagify(limited_msg): + await ctx.send(page) + + def make_active_ongoing_daily_text(self, server): + server = normalize_server_name(server) + + server_events = EventList(self.events).with_server(server) + active_events = server_events.active_only() + events_today = server_events.today_only(server) + + active_special = active_events.with_dungeon_type(DungeonType.Special) + + msg = server + " Events - " + datetime.datetime.now(SERVER_TIMEZONES[server]).strftime('%A, %B %d') + + ongoing_events = active_events.with_length(EventLength.weekly, EventLength.special) + if ongoing_events: + msg += "\n\n" + self.make_active_output('Ongoing Events', ongoing_events) + + active_dailies_events = active_events.with_length(EventLength.daily) + if active_dailies_events: + msg += "\n\n" + self.make_daily_output('Daily Dungeons', active_dailies_events) + return msg - @padevents.command() - @checks.mod_or_permissions(manage_guild=True) - async def active(self, ctx, server): - server = normalizeServer(server) - if server not in SUPPORTED_SERVERS: - await ctx.send("Unsupported server, pick one of NA, KR, JP") - return - - msg = self.makeActiveText(server) - await self.pageOutput(ctx, msg) - - def makeActiveText(self, server): - server_events = EventList(self.events).withServer(server) - active_events = server_events.activeOnly() - pending_events = server_events.pendingOnly() - available_events = server_events.availableOnly() - - msg = "Listing all events for " + server - - special_events = active_events.withType( - EventType.Special).itemsByCloseTime() - if len(special_events) > 0: - msg += "\n\n" + self.makeActiveOutput('Special Events', special_events) - - all_etc_events = active_events.withType(EventType.Etc) - - etc_events = all_etc_events.withDungeonType( - DungeonType.Etc).excludeUnwantedEvents().itemsByCloseTime() - if len(etc_events) > 0: - msg += "\n\n" + self.makeActiveOutput('Etc Events', etc_events) - - # Old-style guerrillas - active_guerrilla_events = active_events.withType(EventType.Guerrilla).items() - if len(active_guerrilla_events) > 0: - msg += "\n\n" + \ - self.makeActiveGuerrillaOutput('Active Guerrillas', active_guerrilla_events) - - guerrilla_events = pending_events.withType(EventType.Guerrilla).items() - if len(guerrilla_events) > 0: - msg += "\n\n" + self.makeFullGuerrillaOutput('Guerrilla Events', guerrilla_events) - - # New-style guerrillas - active_guerrilla_events = active_events.withType(EventType.SpecialWeek).items() - if len(active_guerrilla_events) > 0: - msg += "\n\n" + \ - self.makeActiveGuerrillaOutput('Active Guerrillas', active_guerrilla_events) - - guerrilla_events = pending_events.withType(EventType.SpecialWeek).items() - if len(guerrilla_events) > 0: - msg += "\n\n" + \ - self.makeFullGuerrillaOutput( - 'Guerrilla Events', guerrilla_events, starter_guerilla=True) - - # clean up long headers - msg = msg.replace('-------------------------------------', '-----------------------') + def make_active_limited_text(self, server): + server = normalize_server_name(server) + server_events = EventList(self.events).with_server(server) + events_today = server_events.today_only(server) + limited_events = events_today.with_length(EventLength.limited) + msg = "" + if limited_events: + msg = self.make_full_guerrilla_output('Limited Events', limited_events, server) return msg - async def pageOutput(self, ctx, msg, channel_id=None, format_type=box): - msg = msg.strip() - msg = pagify(msg, ["\n"], shorten_by=20) - for page in msg: - try: - if channel_id is None: - await ctx.send(format_type(page)) - else: - await self.bot.get_channel(int(channel_id)).send(format_type(page)) - except Exception as e: - print("page output failed " + str(e)) - print("tried to print: " + page) - - def makeActiveOutput(self, table_name, event_list): - tbl = prettytable.PrettyTable(["Time", table_name]) + def make_daily_output(self, table_name, event_list): + tbl = prettytable.PrettyTable([table_name]) tbl.hrules = prettytable.HEADER tbl.vrules = prettytable.NONE tbl.align[table_name] = "l" - tbl.align["Time"] = "r" for e in event_list: - tbl.add_row([e.endFromNowFullMin().strip(), e.name_and_modifier]) + tbl.add_row([e.clean_dungeon_name]) return tbl.get_string() - def makeActiveGuerrillaOutput(self, table_name, event_list): - tbl = prettytable.PrettyTable([table_name, "Group", "Time"]) + def make_active_output(self, table_name, event_list): + tbl = prettytable.PrettyTable(["Time", table_name]) tbl.hrules = prettytable.HEADER tbl.vrules = prettytable.NONE tbl.align[table_name] = "l" tbl.align["Time"] = "r" for e in event_list: - tbl.add_row([e.name_and_modifier, e.group, e.endFromNowFullMin().strip()]) + tbl.add_row([e.end_from_now_full_min().strip(), e.clean_dungeon_name]) return tbl.get_string() - def makeFullGuerrillaOutput(self, table_name, event_list, starter_guerilla=False): - events_by_name = defaultdict(list) - for e in event_list: - events_by_name[e.name_and_modifier].append(e) - - rows = list() - grps = ["RED", "BLUE", "GREEN"] if starter_guerilla else ["A", "B", "C", "D", "E"] - for name, events in events_by_name.items(): - events = sorted(events, key=lambda e: e.open_datetime) - events_by_group = defaultdict(list) - for e in events: - events_by_group[e.group].append(e) - - done = False - while not done: - did_work = False - row = list() - row.append(name) - for g in grps: - grp_list = events_by_group[g] - if len(grp_list) == 0: - row.append("") - else: - did_work = True - e = grp_list.pop(0) - row.append(e.toGuerrillaStr()) - if did_work: - rows.append(row) - else: - done = True - - col1 = "Pending" - tbl = prettytable.PrettyTable([col1] + grps) - tbl.align[col1] = "l" + def make_active_guerrilla_output(self, table_name: str, event_list: EventList) -> str: + tbl = prettytable.PrettyTable([table_name, "Time"]) tbl.hrules = prettytable.HEADER - tbl.vrules = prettytable.ALL - - for r in rows: - tbl.add_row(r) + tbl.vrules = prettytable.NONE + tbl.align[table_name] = "l" + tbl.align["Time"] = "r" + for e in event_list: + tbl.add_row([e.clean_dungeon_name, e.end_from_now_full_min().strip()]) + return tbl.get_string() - header = "Times are PT below\n\n" - return header + tbl.get_string() + "\n" + def make_full_guerrilla_output(self, table_name, event_list, server): + return "**" + table_name + "** - " + self.do_partial_text(server, "t", event_list) @commands.command(aliases=['events']) async def eventsna(self, ctx): - """Print upcoming daily events for NA.""" - await self.doPartial(ctx, 'NA') + """Display upcoming daily events for NA.""" + await self.do_partial(ctx, Server.NA, "R") + + @commands.command() + async def eventsna2(self, ctx): + """Display upcoming daily events for NA as a timestamp.""" + await self.do_partial(ctx, Server.NA, "t") @commands.command() async def eventsjp(self, ctx): - """Print upcoming daily events for JP.""" - await self.doPartial(ctx, 'JP') + """Display upcoming daily events for JP.""" + await self.do_partial(ctx, Server.JP, "R") @commands.command() async def eventskr(self, ctx): - """Print upcoming daily events for KR.""" - await self.doPartial(ctx, 'KR') + """Display upcoming daily events for KR.""" + await self.do_partial(ctx, Server.KR, "R") - async def doPartial(self, ctx, server): - server = normalizeServer(server) - if server not in SUPPORTED_SERVERS: - await ctx.send("Unsupported server, pick one of NA, KR, JP") - return + async def do_partial(self, ctx, server: Server, output_type: str): + server = server.value events = EventList(self.events) - events = events.withServer(server) - events = events.inType([EventType.Guerrilla, EventType.SpecialWeek]) + events = events.with_server(server) + events = events.with_dungeon_type(DungeonType.SoloSpecial, DungeonType.Special) + events = events.with_length(EventLength.limited) + output = self.do_partial_text(server, output_type, events) + for page in pagify(output): + await ctx.send(page) - active_events = events.activeOnly().itemsByOpenTime(reverse=True) - pending_events = events.pendingOnly().itemsByOpenTime(reverse=True) + def do_partial_text(self, server: Server, output_type: str, events): + active_events = sorted(events.active_only(), key=lambda e: (e.open_datetime, e.dungeon_name), reverse=True) + pending_events = sorted(events.pending_only(), key=lambda e: (e.open_datetime, e.dungeon_name), reverse=True) - group_to_active_event = {e.group: e for e in active_events} - group_to_pending_event = {e.group: e for e in pending_events} - - active_events = list(group_to_active_event.values()) - pending_events = list(group_to_pending_event.values()) - - active_events.sort(key=lambda e: e.group, reverse=True) - pending_events.sort(key=lambda e: e.group, reverse=True) + active_events.sort(key=lambda e: e.open_datetime) + pending_events.sort(key=lambda e: e.open_datetime) if len(active_events) == 0 and len(pending_events) == 0: - await ctx.send("No events available for " + server) - return + return "No events available for " + server.name - output = "Events for {}".format(server) + output = "**Events for {}**".format(server) if len(active_events) > 0: - output += "\n\n" + "G Remaining Dungeon" - for e in active_events: - output += "\n" + e.toPartialEvent(self) - + output += "\n\n" + "` Remaining Dungeon - Ending Time`" + for event in active_events: + output += "\n" + event.to_partial_event(self, output_type) if len(pending_events) > 0: - output += "\n\n" + "G PT ET ETA Dungeon" - for e in pending_events: - output += "\n" + e.toPartialEvent(self) - - await ctx.send(box(output)) - - -def makeChannelReg(channel_id, server): - server = normalizeServer(server) - return { - "channel_id": channel_id, - "server": server - } - - -class PadEventSettings(CogSettings): - def make_default_settings(self): - config = { - 'guerrilla_regs': [], - 'daily_regs': [], - } - return config - - def listGuerrillaReg(self): - return self.bot_settings['guerrilla_regs'] - - def addGuerrillaReg(self, channel_id, server): - self.listGuerrillaReg().append(makeChannelReg(channel_id, server)) - self.save_settings() - - def checkGuerrillaReg(self, channel_id, server): - return makeChannelReg(channel_id, server) in self.listGuerrillaReg() - - def removeGuerrillaReg(self, channel_id, server): - if self.checkGuerrillaReg(channel_id, server): - self.listGuerrillaReg().remove(makeChannelReg(channel_id, server)) - self.save_settings() - - def listDailyReg(self): - return self.bot_settings['daily_regs'] - - def addDailyReg(self, channel_id, server): - self.listDailyReg().append(makeChannelReg(channel_id, server)) - self.save_settings() - - def checkDailyReg(self, channel_id, server): - return makeChannelReg(channel_id, server) in self.listDailyReg() - - def removeDailyReg(self, channel_id, server): - if self.checkDailyReg(channel_id, server): - self.listDailyReg().remove(makeChannelReg(channel_id, server)) - self.save_settings() - - -class Event: - def __init__(self, scheduled_event: dadguide.DgScheduledEvent): - self.key = scheduled_event.key() - self.server = SUPPORTED_SERVERS[scheduled_event.server_id] - self.open_datetime = scheduled_event.open_datetime - self.close_datetime = scheduled_event.close_datetime - self.group = scheduled_event.group_name - self.dungeon_name = scheduled_event.dungeon.name_na if scheduled_event.dungeon else 'unknown_dungeon' - self.event_name = '' # scheduled_event.event.name if scheduled_event.event else '' - - self.clean_dungeon_name = cleanDungeonNames(self.dungeon_name) - self.clean_event_name = self.event_name.replace('!', '').replace(' ', '') - - self.name_and_modifier = self.clean_dungeon_name - if self.clean_event_name != '': - self.name_and_modifier += ', ' + self.clean_event_name - - self.event_type = EventType(scheduled_event.event_type_id) - self.dungeon_type = DungeonType( - scheduled_event.dungeon.dungeon_type) if scheduled_event.dungeon else DungeonType.Unknown - - def start_from_now_sec(self): - now = datetime.datetime.now(pytz.utc) - return (self.open_datetime - now).total_seconds() - - def end_from_now_sec(self): - now = datetime.datetime.now(pytz.utc) - return (self.close_datetime - now).total_seconds() - - def is_started(self): - """True if past the open time for the event.""" - return self.start_from_now_sec() <= 0 - - def is_finished(self): - """True if past the close time for the event.""" - return self.end_from_now_sec() <= 0 - - def is_active(self): - """True if between open and close time for the event.""" - return self.is_started() and not self.is_finished() - - def is_pending(self): - """True if event has not started.""" - return not self.is_started() - - def is_available(self): - """True if event has not finished.""" - return not self.is_finished() - - def tostr(self): - return fmtTime(self.open_datetime) + "," + fmtTime( - self.close_datetime) + "," + self.group + "," + self.dungeon_code + "," + self.event_type + "," + self.event_seq - - def startPst(self): - tz = pytz.timezone('US/Pacific') - return self.open_datetime.astimezone(tz) - - def startEst(self): - tz = pytz.timezone('US/Eastern') - return self.open_datetime.astimezone(tz) - - def startFromNow(self): - return fmtHrsMins(self.start_from_now_sec()) - - def endFromNow(self): - return fmtHrsMins(self.end_from_now_sec()) - - def endFromNowFullMin(self): - return fmtDaysHrsMinsShort(self.end_from_now_sec()) - - def toGuerrillaStr(self): - return fmtTimeShort(self.startPst()) - - def toDateStr(self): - return self.server + "," + self.group + "," + fmtTime(self.startPst()) + "," + fmtTime( - self.startEst()) + "," + self.startFromNow() - - def groupShortName(self): - return self.group.upper().replace('RED', 'R').replace('BLUE', 'B').replace('GREEN', 'G') - - def groupLongName(self): - return self.group.upper() - - def toPartialEvent(self, pe): - group = self.groupShortName() - if self.is_started(): - return group + " " + self.endFromNow() + " " + self.name_and_modifier - else: - return group + " " + fmtTimeShort(self.startPst()) + " " + fmtTimeShort( - self.startEst()) + " " + self.startFromNow() + " " + self.name_and_modifier - - -class EventList: - def __init__(self, event_list): - self.event_list = event_list - - def withFunc(self, func, exclude=False): - if exclude: - return EventList(list(itertools.filterfalse(func, self.event_list))) + output += "\n\n" + "` Dungeon - " + ("ETA`" if output_type == "R" else "Start Time`") + for event in pending_events: + output += "\n" + event.to_partial_event(self, output_type) + return output + + def get_most_recent_day_change(self): + now = datetime.datetime.utcnow().time() + if now < datetime.time(8): + return "JP" + elif now < datetime.time(15): + return "NA" + elif now < datetime.time(16): + return "KR" else: - return EventList(list(filter(func, self.event_list))) - - def withServer(self, server): - return self.withFunc(lambda e: e.server == normalizeServer(server)) - - def withType(self, event_type): - return self.withFunc(lambda e: e.event_type == event_type) - - def inType(self, event_types): - return self.withFunc(lambda e: e.event_type in event_types) - - def withDungeonType(self, dungeon_type, exclude=False): - return self.withFunc(lambda e: e.dungeon_type == dungeon_type, exclude) - - def withNameContains(self, name, exclude=False): - return self.withFunc(lambda e: name.lower() in e.dungeon_name.lower(), exclude) - - def excludeUnwantedEvents(self): - return self.withFunc(isEventWanted) - - def items(self): - return self.event_list - - def startedOnly(self): - return self.withFunc(lambda e: e.is_started()) - - def pendingOnly(self): - return self.withFunc(lambda e: e.is_pending()) - - def activeOnly(self): - return self.withFunc(lambda e: e.is_active()) - - def availableOnly(self): - return self.withFunc(lambda e: e.is_available()) - - def itemsByOpenTime(self, reverse=False): - return list(sorted(self.event_list, key=(lambda e: (e.open_datetime, e.dungeon_name)), reverse=reverse)) - - def itemsByCloseTime(self, reverse=False): - return list(sorted(self.event_list, key=(lambda e: (e.close_datetime, e.dungeon_name)), reverse=reverse)) - - -# TIME_FMT = """%a %b %d %H:%M:%S %Y""" - - -class EventType(Enum): - Week = 0 - Special = 1 - SpecialWeek = 2 - Guerrilla = 3 - GuerrillaNew = 4 - Etc = -100 - - -class DungeonType(Enum): - Unknown = -1 - Normal = 0 - CoinDailyOther = 1 - Technical = 2 - Etc = 3 - Unknown4 = 4 - Unknown7 = 7 - Unknown9 = 9 - - -def fmtTime(dt): - return dt.strftime("%Y-%m-%d %H:%M") - - -def fmtTimeShort(dt): - return dt.strftime("%H:%M") - - -def fmtHrsMins(seconds): - hours = seconds // 3600 - minutes = (seconds % 3600) // 60 - return '{:2}h {:2}m'.format(int(hours), int(minutes)) - - -def fmtDaysHrsMinsShort(sec): - days = sec // 86400 - sec -= 86400 * days - hours = sec // 3600 - sec -= 3600 * hours - minutes = sec // 60 - - if days > 0: - return '{:2}d {:2}h'.format(int(days), int(hours)) - elif hours > 0: - return '{:2}h {:2}m'.format(int(hours), int(minutes)) - else: - return '{:2}m'.format(int(minutes)) - - -def normalizeServer(server): - server = server.upper() - return 'NA' if server == 'US' else server - - -def isEventWanted(event): - name = event.name_and_modifier.lower() - if 'castle of satan' in name: - # eliminate things like : TAMADRA Invades in [Castle of Satan][Castle of Satan in the Abyss] - return False - - return True - - -def cleanDungeonNames(name): - if 'tamadra invades in some tech' in name.lower(): - return 'Latents invades some Techs & 20x +Eggs' - if '1.5x Bonus Pal Point in multiplay' in name: - name = '[Descends] 1.5x Pal Points in multiplay' - name = name.replace('No Continues', 'No Cont') - name = name.replace('No Continue', 'No Cont') - name = name.replace('Some Limited Time Dungeons', 'Some Guerrillas') - name = name.replace('are added in', 'in') - name = name.replace('!', '') - name = name.replace('Dragon Infestation', 'Dragons') - name = name.replace(' Infestation', 's') - name = name.replace('Daily Descended Dungeon', 'Daily Descends') - name = name.replace('Chance for ', '') - name = name.replace('Jewel of the Spirit', 'Spirit Jewel') - name = name.replace(' & ', '/') - name = name.replace(' / ', '/') - name = name.replace('PAD Radar', 'PADR') - name = name.replace('in normal dungeons', 'in normals') - name = name.replace('Selected ', 'Some ') - name = name.replace('Enhanced ', 'Enh ') - name = name.replace('All Att. Req.', 'All Att.') - name = name.replace('Extreme King Metal Dragon', 'Extreme KMD') - name = name.replace('Golden Mound-Tricolor [Fr/Wt/Wd Only]', 'Golden Mound') - name = name.replace('Gods-Awakening Materials Descended', "Awoken Mats") - name = name.replace('Orb move time 4 sec', '4s move time') - name = name.replace('Awakening Materials Descended', 'Awkn Mats') - name = name.replace('Awakening Materials', 'Awkn Mats') - name = name.replace("Star Treasure Thieves' Den", 'STTD') - name = name.replace('Ruins of the Star Vault', 'Star Vault') - name = name.replace('-★6 or lower Enhanced', '') - - return name + return "JP" diff --git a/padglobal/__init__.py b/padglobal/__init__.py index 2b679c34..e70e8e8d 100644 --- a/padglobal/__init__.py +++ b/padglobal/__init__.py @@ -1,5 +1,9 @@ -from .padglobal import * +from .padglobal import PadGlobal +__red_end_user_data_statement__ = "No personal data is stored." -def setup(bot): - bot.add_cog(PadGlobal(bot)) + +async def setup(bot): + n = PadGlobal(bot) + bot.add_cog(n) if not __import__('asyncio').iscoroutinefunction(bot.add_cog) else await bot.add_cog(n) + bot.loop.create_task(n.register_menu()) diff --git a/padglobal/info.json b/padglobal/info.json new file mode 100644 index 00000000..37243a34 --- /dev/null +++ b/padglobal/info.json @@ -0,0 +1,32 @@ +{ + "author": [ + "The Tsubaki Bot Team" + ], + "description": "The holy text of Tsubaki guides. For the official PadGlobal data, please contact a Tsubaki owner.", + "end_user_data_statement": "No personal data is stored.", + "install_msg": "Use [p]padglobal to get started.", + "short": "Tsubaki guides and tips written by P&D professionals", + "min_bot_version": "3.4.0", + "max_bot_version": "0.0.0", + "min_python_version": [ + 3, + 8, + 1 + ], + "hidden": false, + "disabled": false, + "required_cogs": { + "dbcog": "https://github.com/TsubakiBotPad/pad-cogs", + "globaladmin": "https://github.com/TsubakiBotPad/core-cogs", + "padinfo": "https://github.com/TsubakiBotPad/pad-cogs" + }, + "requirements": [ + "tsutils>=4.0.0", + "aiohttp", + "prettytable", + "pytz" + ], + "tags": [ + "PAD" + ] +} diff --git a/padglobal/menu/closable_embed.py b/padglobal/menu/closable_embed.py new file mode 100644 index 00000000..00a5a29e --- /dev/null +++ b/padglobal/menu/closable_embed.py @@ -0,0 +1,9 @@ +from tsutils.menu.closable_embed_base import ClosableEmbedMenuBase + +from padglobal.view.which import WhichView + + +class ClosableEmbedMenu(ClosableEmbedMenuBase): + view_types = { + WhichView.VIEW_TYPE: WhichView + } diff --git a/padglobal/menu/menu_map.py b/padglobal/menu/menu_map.py new file mode 100644 index 00000000..96d502f4 --- /dev/null +++ b/padglobal/menu/menu_map.py @@ -0,0 +1,7 @@ +from tsutils.menu.closable_embed_base import ClosableEmbedMenuPanes + +from padglobal.menu.closable_embed import ClosableEmbedMenu + +padglobal_menu_map = { + ClosableEmbedMenu.MENU_TYPE: (ClosableEmbedMenu, ClosableEmbedMenuPanes) +} diff --git a/padglobal/padglobal.py b/padglobal/padglobal.py index 368a2234..fc0e48b3 100644 --- a/padglobal/padglobal.py +++ b/padglobal/padglobal.py @@ -1,26 +1,35 @@ import asyncio -import csv import datetime import difflib -import io import json +import logging import os import re from collections import defaultdict +from io import BytesIO -import aiohttp import discord import prettytable -from redbot.core import checks, data_manager -from redbot.core import commands -from redbot.core.bot import Red -from redbot.core.utils.chat_formatting import inline, box, pagify - -import rpadutils -from rpadutils import CogSettings, safe_read_json, replace_emoji_names_with_code,\ - clean_global_mentions, confirm_message - -global PADGLOBAL_COG +import pytz +from redbot.core import checks, commands, data_manager, errors +from redbot.core.utils.chat_formatting import bold, box, humanize_timedelta, inline, pagify +from tsutils.cog_settings import CogSettings +from tsutils.cogs.globaladmin import auth_check +from tsutils.emoji import fix_emojis_for_server, replace_emoji_names_with_code +from tsutils.formatting import clean_global_mentions, strip_right_multiline +from tsutils.json_utils import safe_read_json +from tsutils.menu.view.closable_embed import ClosableEmbedViewState +from tsutils.query_settings.query_settings import QuerySettings +from tsutils.time import NA_TIMEZONE +from tsutils.tsubaki.custom_emoji import get_attribute_emoji_by_monster +from tsutils.tsubaki.links import CLOUDFRONT_URL +from tsutils.user_interaction import get_user_confirmation, get_user_reaction + +from padglobal.menu.closable_embed import ClosableEmbedMenu +from padglobal.menu.menu_map import padglobal_menu_map +from padglobal.view.which import UNKNOWN_EDIT_TIMESTAMP, WhichView, WhichViewProps + +logger = logging.getLogger('red.padbot-cogs.padglobal') def _data_file(file_name: str) -> str: @@ -41,14 +50,16 @@ def _data_file(file_name: str) -> str: BLACKLISTED_CHARACTERS = '^[]*`~_' -PORTRAIT_TEMPLATE = 'https://storage.googleapis.com/mirubot/padimages/{}/portrait/{}.png' +PORTRAIT_TEMPLATE = CLOUDFRONT_URL + '/media/portraits/{0:05d}.png' DISABLED_MSG = 'PAD Global info disabled on this server' FARMABLE_MSG = 'This monster is **farmable** so make as many copies of whichever evos you like.' MP_BUY_MSG = ('This monster can be purchased with MP. **DO NOT** buy MP cards without a good reason' - ', check {}mpdra? for specific recommendations.') -SIMPLE_TREE_MSG = 'This monster appears to be uncontroversial; use the highest evolution.' + ', check `{}mpdra?` for specific recommendations.') +SIMPLE_TREE_MSG = 'This monster appears to be uncontroversial because the skill never changes & there are no branching evos; use the highest evolution: `[{}] {}`.' + +MAX_WHICH_LIST_BEFORE_DM_PROMPT = 30 def mod_help(self, ctx, help_type): @@ -60,39 +71,9 @@ def mod_help(self, ctx, help_type): commands.Command.format_shortdoc_for_context = lambda s, c: mod_help(s, c, "short_doc") -def is_padglobal_admin_check(ctx): - return checks.is_owner() or PADGLOBAL_COG.settings.check_admin(ctx.author.id) - - -def is_padglobal_admin(): - return commands.check(is_padglobal_admin_check) - - -def lookup_named_monster(query: str): - padinfo_cog = PADGLOBAL_COG.bot.get_cog('PadInfo') - if padinfo_cog is None: - raise Exception("Cog not Loaded") - nm, err, debug_info = padinfo_cog._findMonster(str(query)) - return nm, err, debug_info - - -def monster_id_to_monster(monster_id): - dg_cog = PADGLOBAL_COG.bot.get_cog('Dadguide') - if dg_cog is None: - return None - return dg_cog.get_monster_by_id(monster_id) - - -def monster_id_to_named_monster(monster_id): - dg_cog = PADGLOBAL_COG.bot.get_cog('Dadguide') - if dg_cog is None: - return None - return dg_cog.index.monster_id_to_named_monster.get(monster_id, None) - - async def check_enabled(ctx): - """If the server is disabled, print a warning and return False""" - if PADGLOBAL_COG.settings.checkDisabled(ctx.message): + """If the server is disabled, raise a warning and return False""" + if ctx.bot.get_cog("PadGlobal").settings.checkDisabled(ctx.message): msg = await ctx.send(inline(DISABLED_MSG)) await asyncio.sleep(3) await msg.delete() @@ -103,19 +84,50 @@ async def check_enabled(ctx): class PadGlobal(commands.Cog): """Global PAD commands.""" - def __init__(self, bot: Red, *args, **kwargs): + menu_map = padglobal_menu_map + + def __init__(self, bot, *args, **kwargs): super().__init__(*args, **kwargs) - global PADGLOBAL_COG - PADGLOBAL_COG = self self.bot = bot + + GADMIN_COG = self.bot.get_cog("GlobalAdmin") + if GADMIN_COG: + GADMIN_COG.register_perm("contentadmin") + else: + raise errors.CogLoadError("Global Administration cog must be loaded. Make sure it's " + "installed from core-cogs and load it via `^load globaladmin`") + self.file_path = _data_file('commands.json') self.c_commands = safe_read_json(self.file_path) self.settings = PadGlobalSettings("padglobal") self._export_data() - def _export_data(self): + async def red_get_data_for_user(self, *, user_id): + """Get a user's personal data.""" + data = "No data is stored for user with ID {}.\n".format(user_id) + return {"user_data.txt": BytesIO(data.encode())} + + async def red_delete_data_for_user(self, *, requester, user_id): + """Delete a user's personal data. + + No personal data is stored in this cog. + """ + return + async def register_menu(self): + await self.bot.wait_until_ready() + menulistener = self.bot.get_cog("MenuListener") + if menulistener is None: + logger.warning("MenuListener is not loaded.") + return + await menulistener.register(self) + + async def get_menu_default_data(self, ims): + data = {} + return data + + def _export_data(self): faq_and_boards = self.settings.faq() + self.settings.boards() general = {k: v for k, v in self.c_commands.items() if k not in faq_and_boards} faq = {k: v for k, v in self.c_commands.items() if k in self.settings.faq()} @@ -141,7 +153,7 @@ def _export_data(self): json.dump(results, f, indent=4) @commands.command() - @is_padglobal_admin() + @auth_check('contentadmin') async def breakglass(self, ctx, *, reason: str): """Shuts down the bot, for emergency use only. @@ -156,17 +168,16 @@ class of problems where the bot can read messages but not respond, so this msg = '------------------------\n' msg += '{} shut down the bot because: {}\n'.format(ctx.author.name, reason) msg += '------------------------\n' - print(msg) + logger.critical(msg) try: - app_info = await self.bot.application_info() - owner = app_info.owner - await owner.send(msg) - await ctx.send("Owner has been notified, shutting down...") + for uid in self.bot.owner_ids: + await self.bot.get_user(uid).send(msg) + await ctx.send("Owners have been notified, shutting down...") except Exception as ex: - print('Failed to notifiy for breakglass: ' + str(ex)) + logger.exception("Failed to notifiy for breakglass.") - await self.bot.shutdown() + await self.bot.shutdown(restart=True) @commands.command() @checks.admin_or_permissions(manage_guild=True) @@ -180,114 +191,13 @@ async def togglepadglobal(self, ctx): status = 'disabled' if self.settings.checkDisabled(ctx.message) else 'enabled' await ctx.send(inline('PAD Global commands {} on this server').format(status)) - @commands.command() - @is_padglobal_admin() - async def debugiddump(self, ctx): - padinfo_cog = self.bot.get_cog('PadInfo') - mi = padinfo_cog.index_all - - async def write_send(nn_map, file_name): - data_holder = io.StringIO() - writer = csv.writer(data_holder) - for nn, nm in nn_map.items(): - writer.writerow([nn, nm.monster_no_na, nm.name_na]) - bytes_data = io.BytesIO(data_holder.getvalue().encode()) - await ctx.channel.send(file=discord.File(bytes_data, file_name)) - - await write_send(mi.all_entries, 'all_entries.csv') - await write_send(mi.two_word_entries, 'two_word_entries.csv') - - @commands.command(aliases=['iddebug']) - @is_padglobal_admin() - async def debugid(self, ctx, *, query): - padinfo_cog = self.bot.get_cog('PadInfo') - # m is a named monster - m, err, debug_info = lookup_named_monster(query) - - if m is None: - await ctx.send(box('No match: ' + err)) - return - - msg = "{}. {}".format(m.monster_no_na, m.name_na) - msg += "\nLookup type: {}".format(debug_info) - - def list_or_none(l): - if len(l) == 1: - return '\n\t{}'.format(''.join(l)) - elif len(l): - return '\n\t' + '\n\t'.join(sorted(l)) - else: - return 'NONE' - - msg += "\n\nNickname original components:" - msg += "\n monster_basename: {}".format(m.monster_basename) - msg += "\n group_computed_basename: {}".format(m.group_computed_basename) - msg += "\n extra_nicknames: {}".format(list_or_none(m.extra_nicknames)) - - msg += "\n\nNickname final components:" - msg += "\n basenames: {}".format(list_or_none(m.group_basenames)) - msg += "\n prefixes: {}".format(list_or_none(m.prefixes)) - - msg += "\n\nAccepted nickname entries:" - accepted_nn = list(filter(lambda nn: m.monster_id == padinfo_cog.index_all.all_entries[nn].monster_id, - m.final_nicknames)) - accepted_twnn = list(filter(lambda nn: m.monster_id == padinfo_cog.index_all.two_word_entries[nn].monster_id, - m.final_two_word_nicknames)) - - msg += "\n nicknames: {}".format(list_or_none(accepted_nn)) - msg += "\n two_word_nicknames: {}".format(list_or_none(accepted_twnn)) - - msg += "\n\nOverwritten nickname entries:" - replaced_nn = list(filter(lambda nn: nn not in accepted_nn, - m.final_nicknames)) - - replaced_twnn = list(filter(lambda nn: nn not in accepted_twnn, - m.final_two_word_nicknames)) - - replaced_nn_info = map(lambda nn: ( - nn, padinfo_cog.index_all.all_entries[nn]), replaced_nn) - replaced_twnn_info = map( - lambda nn: (nn, padinfo_cog.index_all.two_word_entries[nn]), replaced_twnn) - - replaced_nn_text = list(map(lambda nn_info: '{} : {}. {}'.format( - nn_info[0], nn_info[1].monster_no_na, nn_info[1].name_na), - replaced_nn_info)) - - replaced_twnn_text = list(map(lambda nn_info: '{} : {}. {}'.format( - nn_info[0], nn_info[1].monster_no_na, nn_info[1].name_na), - replaced_twnn_info)) - - msg += "\n nicknames: {}".format(list_or_none(replaced_nn_text)) - msg += "\n two_word_nicknames: {}".format(list_or_none(replaced_twnn_text)) - - msg += "\n\nNickname entry sort parts:" - msg += "\n (is_low_priority, group_size, monster_no_na) : ({}, {}, {})".format( - m.is_low_priority, m.group_size, m.monster_no_na) - - msg += "\n\nMatch selection sort parts:" - msg += "\n (is_low_priority, rarity, monster_no_na) : ({}, {}, {})".format( - m.is_low_priority, m.rarity, m.monster_no_na) - - sent_messages = [] - for page in pagify(msg): - sent_messages.append(await ctx.send(box(page))) - await rpadutils.await_and_remove(self.bot, sent_messages[-1], ctx.author, - delete_msgs=sent_messages, timeout=30) - - @commands.command() - @is_padglobal_admin() - async def forceindexreload(self, ctx): - await ctx.send('starting reload') - dadguide_cog = self.bot.get_cog('Dadguide') - await dadguide_cog.reload_config_files() - padinfo_cog = self.bot.get_cog('PadInfo') - await padinfo_cog.refresh_index() - await ctx.send('finished reload') - - @commands.group(aliases = ['pdg']) - @is_padglobal_admin() + @commands.group(aliases=['pdg']) + @auth_check('contentadmin') async def padglobal(self, ctx): - """PAD global custom commands.""" + """PAD global custom commands. + + See also `[p]pglossary`, `[p]pboss`, `[p]pwhich`, and `[p]pguide`. + """ @padglobal.command() async def say(self, ctx, *, text: str): @@ -305,7 +215,7 @@ async def edit(self, ctx, command: str, *, text: str): """Edit a custom command or alias""" await self._add(ctx, command, text, False) - async def _add(self, ctx, command, text, confirm = True): + async def _add(self, ctx, command, text, confirm=True): command = command.lower() text = clean_global_mentions(text) text = text.replace(u'\u200b', '') @@ -323,37 +233,56 @@ async def _add(self, ctx, command, text, confirm = True): self.c_commands = {} if text in self.c_commands: - op = 'ALIASED' - if self.c_commands[text] in self.c_commands: - await ctx.send("You cannot alias an alias") + op = 'aliased' + if text == command: + await ctx.send('You cannot alias something to itself.') return + if self.c_commands[text] in self.c_commands: + source = self.c_commands[text] + if await get_user_confirmation(ctx, f'{inline(text)} is already an alias for {inline(source)},' + f' and you can\'t alias to an alias. Would you like to' + f' alias to {inline(source)} instead?'): + # change target + text = source + else: + return elif command in self.c_commands: - op = 'EDITED' + op = 'edited' ted = self.c_commands[command] alias = False while ted in self.c_commands: ted = self.c_commands[ted] alias = True if confirm: - conf = await confirm_message(ctx,"Are you sure you want to edit the {}command {}?" \ - .format("alias to " if alias else "", ted)) + conf = await get_user_confirmation(ctx, "Are you sure you want to edit the {}command {}?" + "".format("alias to " if alias else "", ted)) if not conf: return else: - op = 'ADDED' + op = 'added' self.c_commands[command] = text json.dump(self.c_commands, open(self.file_path, 'w+')) - await ctx.send("PAD command successfully {}.".format(op)) + await ctx.send("PAD command successfully {}.".format(bold(op))) - @padglobal.command(aliases=['rmalias', 'delalias']) + @padglobal.command(aliases=['rmalias', 'delalias', 'remove', 'rm', 'del']) async def delete(self, ctx, command: str): """Deletes a PAD global command or alias Example: [p]padglobal delete yourcommand""" command = command.lower() + + aliases = await self._find_aliases(command) + if aliases: + if not await get_user_confirmation(ctx, + 'Are you sure? `{}` has {} alias(es): `{}` which will also be deleted.' + ''.format(command, bold(str(len(aliases))), '`, `'.join(aliases))): + await ctx.send('Cancelling delete of `{}`.'.format(command)) + return + if command in self.c_commands: + alias = self.c_commands[command] in self.c_commands ocm = self.c_commands.copy() self.c_commands.pop(command, None) todel = [command] @@ -364,10 +293,108 @@ async def delete(self, ctx, command: str): self.c_commands.pop(comm, None) todel.append(comm) json.dump(self.c_commands, open(self.file_path, 'w+')) - await ctx.send("PAD command successfully deleted.") + await ctx.send("PAD {} successfully deleted.".format(bold('alias' if alias else 'command'))) else: await ctx.send("PAD command doesn't exist.") + @padglobal.command() + async def prepend(self, ctx, command: str, *, addition): + """Prepend the additional text to an existing command before a blank line.""" + await self._concatenate(ctx, command, 'prepend', addition) + + @padglobal.command() + async def append(self, ctx, command: str, *, addition): + """Append the additional text to an existing command after a blank line.""" + await self._concatenate(ctx, command, 'append', addition) + + async def _concatenate(self, ctx, command: str, operation: str, addition): + # the same cleaning that padglobal add does + command = command.lower() + addition = clean_global_mentions(addition) + addition = addition.replace(u'\u200b', '') + addition = replace_emoji_names_with_code(self._get_emojis(), addition) + + corrected_cmd = self._lookup_command(command) + alias = False + if not corrected_cmd: + await ctx.send("Could not find a good match for command `{}`.".format(command)) + return + result = self.c_commands.get(corrected_cmd, None) + # go a level deeper if trying to append to an alias + source_cmd = None + if result in self.c_commands: + alias = True + source_cmd = result + result = self.c_commands[result] + + if operation == 'prepend': + result = "{}\n\n{}".format(addition, result) + elif operation == 'append': + result = "{}\n\n{}".format(result, addition) + else: + raise KeyError("Invalid operation: Must be \'prepend\' or \'append\'") + + if alias: + self.c_commands[source_cmd] = result + else: + self.c_commands[corrected_cmd] = result + json.dump(self.c_commands, open(self.file_path, 'w+')) + + await ctx.send("Successfully {}ed to {}PAD command `{}`.".format(operation, "source " if alias else "", + source_cmd if alias else corrected_cmd)) + + @padglobal.command() + async def eventswap(self, ctx): + """Shift contents of nextevent command to currentevent""" + if 'currentevent' not in self.c_commands or 'nextevent' not in self.c_commands: + await ctx.send(f"Please populate both {inline('currentevent')} and {inline('nextevent')} first." + f" Cancelling.") + return + + current_text = self.c_commands['currentevent'] + next_text = self.c_commands['nextevent'] + + if not await get_user_confirmation(ctx, "Are you sure you want to update for the new event?"): + await ctx.send("Making no change to current and next event.") + return + + self.c_commands['lastevent'] = current_text + self.c_commands['currentevent'] = next_text + self.c_commands['nextevent'] = "N/A" + + json.dump(self.c_commands, open(self.file_path, 'w+')) + await ctx.send(f"Okay, I've updated {inline('lastevent')} and {inline('currentevent')} accordingly," + f" and cleared {inline('nextevent')}.") + + @padglobal.command() + async def rename(self, ctx, old_name, new_name): + """Rename a PAD global command""" + if old_name not in self.c_commands: + await ctx.send(f"Please populate {inline(old_name)} first.") + return + if new_name in self.c_commands: + await ctx.send("A command already exists with that name.") + return + if not await get_user_confirmation(ctx, f"Are you sure you want to rename command {inline(old_name)} to {inline(new_name)}?"): + await ctx.send("The command was not renamed.") + return + aliases = await self._find_aliases(old_name) + for alias in aliases: + self.c_commands[alias] = new_name + + self.c_commands[new_name] = self.c_commands[old_name] + self.c_commands.pop(old_name) + + json.dump(self.c_commands, open(self.file_path, 'w+')) + await ctx.send(f"Okay, I've deleted {inline(old_name)} and moved its contents to {inline(new_name)}.") + + async def _find_aliases(self, command: str): + aliases = [] + for cmd in self.c_commands: + if self.c_commands[cmd] == command: + aliases.append(cmd) + return aliases + @padglobal.command() async def setgeneral(self, ctx, command: str): """Sets a command to show up in [p]pad (the default). @@ -429,85 +456,52 @@ async def pad(self, ctx): """Shows PAD global command list""" configured = self.settings.faq() + self.settings.boards() cmdlist = {k: v for k, v in self.c_commands.items() if k not in configured} - await self.print_cmdlist(ctx, cmdlist) + await self.send_cmdlist(ctx, cmdlist) @commands.command() @commands.check(check_enabled) async def padfaq(self, ctx): """Shows PAD FAQ command list""" cmdlist = {k: v for k, v in self.c_commands.items() if k in self.settings.faq()} - await self.print_cmdlist(ctx, cmdlist) + await self.send_cmdlist(ctx, cmdlist) @commands.command() @commands.check(check_enabled) async def boards(self, ctx): """Shows PAD Boards command list""" cmdlist = {k: v for k, v in self.c_commands.items() if k in self.settings.boards()} - await self.print_cmdlist(ctx, cmdlist) + await self.send_cmdlist(ctx, cmdlist) - async def print_cmdlist(self, ctx, cmdlist, inline=False): - #TODO: Write a docstring once I figure out what this does + async def send_cmdlist(self, ctx, cmdlist, write_inline=False): if not cmdlist: await ctx.send("There are no padglobal commands yet") return - prefix = ctx.prefix - cmds = list(cmdlist.keys()) - prefixes = defaultdict(int) + prefixes = defaultdict(list) - for c in cmds: - m = re.match(r'^([a-zA-Z]+)\d+$', c) + for c in cmdlist: + m = re.match(r'^([a-zA-Z]+)(\d+)$', c) if m: - grp = m.group(1) - prefixes[grp] = prefixes[grp] + 1 + prefixes[m.group(1)].append(m.group(2)) - good_prefixes = [cmd for cmd, cnt in prefixes.items() if cnt > 1] - prefix_to_suffix = defaultdict(list) - prefix_to_other = defaultdict(list) + prefix_to_suffix = {cmd: cnt for cmd, cnt in prefixes.items() if len(cnt) > 1} - i = 0 msg = PAD_CMD_HEADER.format(ctx.prefix) + "\n" - if inline: - for cmd in sorted([cmd for cmd in cmdlist.keys()]): - msg += " {} : {}\n".format(cmd, cmdlist[cmd]) - for page in pagify(msg): - await ctx.author.send(box(page)) - return + done_prefixes = [] - for cmd in sorted([cmd for cmd in cmdlist.keys()]): - m = re.match(r'^([a-zA-Z]+)(\d+)$', cmd) + for cmd in sorted(cmdlist): + m = re.match(r'^([a-zA-Z]+)\d+$', cmd) if m: prefix = m.group(1) - if prefix in good_prefixes: - suffix = m.group(2) - prefix_to_suffix[prefix].append(suffix) - continue - - should_skip = False - for good_prefix in good_prefixes: - if cmd.startswith(good_prefix): - prefix_to_other[prefix].append(cmd) - should_skip = True - break - if should_skip: - continue - - msg += " {}{}\n".format(ctx.prefix, cmd) - - if prefix_to_suffix: - msg += "\nThe following commands are indexed:\n" - for prefix in sorted(prefix_to_suffix.keys()): - msg += " {}{}[n]:\n ".format(ctx.prefix, prefix) - - for suffix in sorted(map(int, prefix_to_suffix[prefix])): - msg += " {}{}".format(prefix, suffix) - - if len(prefix_to_other[prefix]): - msg += "\n" - for cmd in sorted(prefix_to_other[prefix]): - msg += " {}{}".format(ctx.prefix, cmd) - msg += "\n\n" + if prefix in prefix_to_suffix and prefix not in done_prefixes: + msg += " {}{}[n]:\n ".format(ctx.prefix, prefix) + for suffix in sorted(map(int, prefix_to_suffix[prefix])): + msg += " {}{}".format(prefix, suffix) + msg += '\n' + done_prefixes.append(prefix) + else: + msg += " {}{}\n".format(ctx.prefix, cmd) for page in pagify(msg): await ctx.author.send(box(page)) @@ -531,6 +525,10 @@ async def padto(self, ctx, to_user: discord.Member, *, term: str): """ corrected_term = self._lookup_command(term) result = self.c_commands.get(corrected_term, None) + # go a level deeper if this is an alias + if result in self.c_commands: + result = self.c_commands[result] + await self._do_send_term(ctx, to_user, term, corrected_term, result) async def _do_send_term(self, ctx, to_user: discord.Member, term, corrected_term, result): @@ -556,7 +554,7 @@ async def glossary(self, ctx, *, term: str = None): term, definition = self.lookup_glossary(term) if definition: definition_output = '**{}** : {}'.format(term, definition) - await ctx.send(definition_output) + await ctx.send(self.emojify(definition_output)) else: await ctx.send(inline('No definition found')) return @@ -592,48 +590,66 @@ def lookup_glossary(self, term): term = matches[0] return term, glossary[term] - @padglobal.command() - async def addglossary(self, ctx, term, *, definition): + @commands.group() + @auth_check('contentadmin') + async def pglossary(self, ctx): + """Commands related to the PAD global glossary.""" + + @pglossary.command(name='add') + async def pglossary_add(self, ctx, term, *, definition): """Adds a term to the glossary. If you want to use a multiple word term, enclose it in quotes. - e.x. [p]padglobal addglossary alb Awoken Liu Bei - e.x. [p]padglobal addglossary "never dathena" NA will never get dathena + e.x. [p]pglossary add alb Awoken Liu Bei + e.x. [p]pglossary add "never dathena" NA will never get dathena """ + await self._pglossary_add(ctx, term, definition, True) + + @pglossary.command(name='edit') + async def pglossary_edit(self, ctx, term, *, definition): + """Edit a term from the glossary.""" + await self._pglossary_add(ctx, term, definition, False) + + async def _pglossary_add(self, ctx, term, definition, need_confirm=True): term = term.lower() definition = clean_global_mentions(definition) definition = definition.replace(u'\u200b', '') definition = replace_emoji_names_with_code(self._get_emojis(), definition) - op = 'EDITED' if term in self.settings.glossary() else 'ADDED' + op = 'edited' if term in self.settings.glossary() else 'added' + if op == 'edited' and need_confirm: + if not await get_user_confirmation(ctx, + "Are you sure you want to edit the glossary info for {}?".format(term)): + return self.settings.addGlossary(term, definition) - await ctx.send("PAD glossary term successfully {}.".format(op)) + await ctx.send("PAD glossary term successfully {}.".format(bold(op))) - @padglobal.command() - async def rmglossary(self, ctx, *, term): + @pglossary.command(name='remove', aliases=['rm', 'delete', 'del']) + async def pglossary_remove(self, ctx, *, term): """Removes a term from the glossary.""" term = term.lower() if term not in self.settings.glossary(): await ctx.send("Glossary item doesn't exist.") return - + if not await get_user_confirmation(ctx, + "Are you sure you want to globally remove the glossary data for {}?".format( + term)): + return self.settings.rmGlossary(term) - await ctx.send("done") + await ctx.tick() @commands.command() @commands.check(check_enabled) async def boss(self, ctx, *, term: str = None): """Shows boss skill entries""" if term: - term_new, definition = self.lookup_boss(term) + name, definition = await self.lookup_boss(term, ctx) if definition: - if term_new != term.lower(): - await ctx.send('No entry for {} found, corrected to {}'.format(term, term_new)) - await ctx.send(definition) + await ctx.send(self.emojify(definition)) else: await ctx.send(inline('No mechanics found')) return - msg = self.boss_to_text(ctx) + msg = await self.boss_to_text(ctx) for page in pagify(msg): await ctx.author.send(page) @@ -645,59 +661,106 @@ async def bosslist(self, ctx): for page in pagify(msg): await ctx.author.send(page) - def lookup_boss(self, term): - bosses = self.settings.boss() - term = term.lower() - definition = bosses.get(term, None) - if definition: - return term, definition - matches = self._get_corrected_cmds(term, bosses.keys()) + async def lookup_boss(self, term, ctx): + dbcog = await self.get_dbcog() - if not matches: - matches = difflib.get_close_matches(term, bosses.keys(), n=1, cutoff=.8) + term = term.lower().replace('?', '') + m = await dbcog.find_monster(term, ctx.author.id) + if m is None: + return None, None - if not matches: - return term, None - else: - term = matches[0] - return term, bosses[term] + m = dbcog.database.graph.get_base_monster(m) + name = get_attribute_emoji_by_monster(m) + " " + m.name_en.split(",")[-1].strip() + monster_id = m.monster_id + definition = self.settings.boss().get(monster_id, None) + + return name, definition - def boss_to_text(self, ctx): + async def boss_to_text(self, ctx): bosses = self.settings.boss() - msg = '__**PAD Boss Mechanics (also check out {0}pad / {0}padfaq / {0}boards / {0}which / {0}glossary)**__'.format(ctx.prefix) + dbcog = await self.get_dbcog() + msg = '__**Available PAD Boss Mechanics (also check out {0}pad / {0}padfaq / {0}boards / {0}which / {0}glossary)**__'.format( + ctx.prefix) for term in sorted(bosses.keys()): - definition = bosses[term] - msg += '\n**{}**\n{}'.format(term, definition) + m = await dbcog.find_monster(str(term), ctx.author.id) + if not m: # monster not found + continue + msg += '\n[{}] {}'.format(term, m.name_en) return msg def boss_to_text_index(self, ctx): bosses = self.settings.boss() - msg = '__**Available PAD Boss Mechanics (also check out {0}pad / {0}padfaq / {0}boards / {0}which / {0}glossary)**__'.format(ctx.prefix) - msg = msg + '\n' + ',\n'.join(sorted(bosses.keys())) + msg = '__**Available PAD Boss Mechanics (also check out {0}pad / {0}padfaq / {0}boards / {0}which / {0}glossary)**__'.format( + ctx.prefix) + msg = msg + '\n' + ',\n'.join(map(str, sorted(bosses.keys()))) return msg - @padglobal.command() - async def addboss(self, ctx, term, *, definition): + @commands.group() + @auth_check('contentadmin') + async def pboss(self, ctx): + """Commands related to PAD global boss mechanics.""" + + @pboss.command(name='add') + async def pboss_add(self, ctx, term, *, definition): """Adds a set of boss mechanics. - If you want to use a multiple word boss name, enclose it in quotes.""" + + If you want to use a multiple word boss name, enclose it in quotes. + """ + await self._pboss_add(ctx, term, definition, True) + + @pboss.command(name='edit') + async def pboss_edit(self, ctx, term, *, definition): + """Edit a set of boss mechanics.""" + await self._pboss_add(ctx, term, definition, False) + + async def _pboss_add(self, ctx, term, definition, need_confirm=True): + dbcog = await self.get_dbcog() + pdicog = self.bot.get_cog("PadInfo") + term = term.lower() - op = 'EDITED' if term in self.settings.boss() else 'ADDED' + m = await dbcog.find_monster(term, ctx.author.id) + if m is None: + await ctx.send(f"No monster found for `{term}`") + return + + base = dbcog.database.graph.get_base_monster(m) + + op = 'edited' if base.monster_id in self.settings.boss() else 'added' + if op == 'edited' and need_confirm: + if not await get_user_confirmation(ctx, + "Are you sure you want to edit the boss info for {}?".format( + base.name_en)): + return definition = clean_global_mentions(definition) definition = definition.replace(u'\u200b', '') definition = replace_emoji_names_with_code(self._get_emojis(), definition) - self.settings.addBoss(term, definition) - await ctx.send("PAD boss mechanics successfully {}.".format(op)) + self.settings.addBoss(base.monster_id, definition) + await ctx.send("PAD boss mechanics successfully {}.".format(bold(op))) + + @pboss.command(name='remove', aliases=['rm', 'delete', 'del']) + async def pboss_remove(self, ctx, *, term): + """Removes a set of boss mechanics.""" + dbcog = await self.get_dbcog() + pdicog = self.bot.get_cog("PadInfo") - @padglobal.command() - async def rmboss(self, ctx, *, term): - """Adds a set of boss mechanics.""" term = term.lower() - if term not in self.settings.boss(): + m = await dbcog.find_monster(term, ctx.author.id) + if m is None: + await ctx.send(f"No monster found for `{term}`. Make sure you didn't use quotes.") + return + + base = dbcog.database.graph.get_base_monster(m) + + if base.monster_id not in self.settings.boss(): await ctx.send("Boss mechanics item doesn't exist.") return + if not await get_user_confirmation(ctx, + "Are you sure you want to globally remove the boss data for {}?".format( + base.name_en)): + return - self.settings.rmBoss(term) - await ctx.send("done") + self.settings.rmBoss(base.monster_id) + await ctx.tick() @commands.command() @commands.check(check_enabled) @@ -705,8 +768,10 @@ async def which(self, ctx, *, term: str = None): """Shows PAD Which Monster entries""" if term is None: - await ctx.author.send('__**PAD Which Monster**__ *(also check out {0}pad / {0}padfaq / {0}boards / {0}glossary)*'.format(ctx.prefix)) - msg = self.which_to_text() + await ctx.author.send( + '__**PAD Which Monster**__ *(also check out {0}pad / {0}padfaq / {0}boards / {0}glossary)*'.format( + ctx.prefix)) + msg = await self.which_to_text() for page in pagify(msg): await ctx.author.send(box(page)) return @@ -714,23 +779,30 @@ async def which(self, ctx, *, term: str = None): name, definition, timestamp, success = await self._resolve_which(ctx, term) if name is None or definition is None: return - if not success: - await ctx.send('`Which {}`\n{}'.format(name, definition)) - return - await ctx.send(inline('Which {} - Last Updated {}'.format(name, timestamp))) - await ctx.send(definition) + query_settings = await QuerySettings.extract_raw(ctx.author, self.bot, term) + original_author_id = ctx.message.author.id + menu = ClosableEmbedMenu.menu() + props = WhichViewProps(name=name, definition=definition, timestamp=timestamp, success=success) + state = ClosableEmbedViewState(original_author_id, ClosableEmbedMenu.MENU_TYPE, term, + query_settings, WhichView.VIEW_TYPE, props) + await menu.create(ctx, state) async def _resolve_which(self, ctx, term): + dbcog = await self.get_dbcog() + db_context = dbcog.database + term = term.lower().replace('?', '') - nm, _, _ = lookup_named_monster(term) - if nm is None: + m = await dbcog.find_monster(term, ctx.author.id) + if m is None: await ctx.send(inline('No monster matched that query')) return None, None, None, None - name = nm.group_computed_basename.title() - monster_id = nm.base_monster_no + m = db_context.graph.get_base_monster(m) + + name = get_attribute_emoji_by_monster(m) + " " + m.name_en.split(",")[-1].strip() + monster_id = m.monster_id definition = self.settings.which().get(monster_id, None) - timestamp = "2000-01-01" + timestamp = UNKNOWN_EDIT_TIMESTAMP if isinstance(definition, list): definition, timestamp = definition @@ -738,16 +810,17 @@ async def _resolve_which(self, ctx, term): if definition is not None: return name, definition, timestamp, True - monster = monster_id_to_monster(monster_id) + monster = dbcog.get_monster(monster_id) - if monster.mp_evo: + if db_context.graph.monster_is_mp_evo(monster) and not monster.in_rem: return name, MP_BUY_MSG.format(ctx.prefix), None, False - elif monster.farmable_evo: + elif db_context.graph.monster_is_farmable_evo(monster): return name, FARMABLE_MSG, None, False - elif check_simple_tree(monster): - return name, SIMPLE_TREE_MSG, None, False + elif check_simple_tree(monster, db_context): + top_monster = db_context.graph.get_numerical_sort_top_monster(monster) + return name, SIMPLE_TREE_MSG.format(top_monster.monster_id, top_monster.name_en), None, False else: - await ctx.send(inline('No which info for {} (#{})'.format(name, monster_id))) + await ctx.send('No which info for {} (#{})'.format(name, monster_id)) return None, None, None, None @commands.command() @@ -762,18 +835,17 @@ async def whichto(self, ctx, to_user: discord.Member, *, term: str): return if not success: - await ctx.send('`Which {}`\n{}'.format(name, definition)) + await ctx.send('Which {}\n{}'.format(name, definition)) return await self._do_send_which(ctx, to_user, name, definition, timestamp) - def which_to_text(self): + async def which_to_text(self): monsters = defaultdict(list) for monster_id in self.settings.which(): - m = monster_id_to_monster(monster_id) - nm = monster_id_to_named_monster(monster_id) - if m is None or nm is None: + m = (await self.get_dbcog()).get_monster(monster_id) + if m is None: continue - name = nm.group_computed_basename.title() + name = m.name_en.split(", ")[-1] grp = m.series.name monsters[grp].append(name) @@ -784,7 +856,7 @@ def which_to_text(self): for grp in sorted(monsters.keys()): tbl.add_row([grp, ', '.join(sorted(monsters[grp]))]) - tbl_string = rpadutils.strip_right_multiline(tbl.get_string()) + tbl_string = strip_right_multiline(tbl.get_string()) return tbl_string async def _do_send_which(self, ctx, to_user: discord.Member, name, definition, timestamp): @@ -793,34 +865,74 @@ async def _do_send_which(self, ctx, to_user: discord.Member, name, definition, t result = "{} asked me to send you this:\n{}".format( ctx.author.name, result_output) - await to_user.send(result) - msg = "Sent info on {} to {}".format(name, to_user.name) - await ctx.send(inline(msg)) + for page in pagify(result): + await to_user.send(page) + await ctx.send("Sent info on {} to {}".format(name, to_user.name)) - @padglobal.command() - async def addwhich(self, ctx, monster_id: int, *, definition): + @commands.group() + @auth_check('contentadmin') + async def pwhich(self, ctx): + """Commands related to PAD global which definitions.""" + + @pwhich.command(name='add') + async def pwhich_add(self, ctx, term, *, definition): """Adds an entry to the which monster evo list. - If you provide a monster ID, the term will be entered for that monster tree. - e.x. [p]padglobal addwhich 3818 take the pixel one + Accepts queries. The which text will be entered for the resulting monster's tree. + e.x. [p]pwhich add 3818 take the pixel one """ - m = monster_id_to_monster(monster_id) - if m != m.base_monster: - m = m.base_monster - await ctx.send("I think you meant {} for {}.".format(m.monster_no_na, m.name_na)) + await self._pwhich_add(ctx, term, definition, True) + + @pwhich.command(name='edit') + async def pwhich_edit(self, ctx, term, *, definition): + """Edit an entry from the which monster evo list.""" + await self._pwhich_add(ctx, term, definition, False) + + async def _pwhich_add(self, ctx, term, definition, need_confirm=True): + dbcog = await self.get_dbcog() + + term = term.lower() + m = await dbcog.find_monster(term, ctx.author.id) + if m is None: + await ctx.send(f"No monster found for `{term}`") + return + + base_monster = dbcog.database.graph.get_base_monster(m) + if m != base_monster: + m = base_monster + await ctx.send("I think you meant {} for {}.".format(m.monster_no_na, m.name_en)) name = m.monster_id - op = 'EDITED' if name in self.settings.which() else 'ADDED' + is_int = re.fullmatch(r'\d+', term) + + op = 'edited' if name in self.settings.which() else 'added' + if not is_int or op == 'edited' and need_confirm: + if not await get_user_confirmation(ctx, "Are you sure you want to {} which info for {} [{}] {}?".format( + 'edit the' if op == 'edited' else 'add new', + get_attribute_emoji_by_monster(m), + m.monster_no_na, + m.name_en)): + return + + definition = clean_global_mentions(definition) + definition = definition.replace(u'\u200b', '') + definition = replace_emoji_names_with_code(self._get_emojis(), definition) self.settings.addWhich(name, definition) - await ctx.send("PAD which info successfully {}.".format(op)) + await ctx.send("PAD which info successfully {} for [{}] {}.".format(bold(op), m.monster_no_na, m.name_en)) - @padglobal.command() - async def rmwhich(self, ctx, *, monster_id: int): + @pwhich.command(name='remove', aliases=['rm', 'delete', 'del']) + async def pwhich_remove(self, ctx, *, monster_id: int): """Removes an entry from the which monster evo list.""" - m = monster_id_to_monster(monster_id) - if m != m.base_monster: - m = m.base_monster - await ctx.send("I think you meant {} for {}.".format(m.monster_no_na, m.name_na)) + dbcog = await self.get_dbcog() + m = dbcog.get_monster(monster_id) + base_monster = dbcog.database.graph.get_base_monster(m) + if m != base_monster: + m = base_monster + await ctx.send("I think you meant {} for {}.".format(m.monster_no_na, m.name_en)) + if not await get_user_confirmation(ctx, + "Are you sure you want to globally remove the which data for {}?".format( + m.name_en)): + return name = m.monster_id if name not in self.settings.which(): @@ -828,24 +940,110 @@ async def rmwhich(self, ctx, *, monster_id: int): return self.settings.rmWhich(name) - await ctx.send("done") + await ctx.tick() + + @pwhich.command(name='prepend') + async def pwhich_prepend(self, ctx, term: str, *, addition): + """Prepend the additional text to an existing which entry before a blank line.""" + await self._concatenate_which(ctx, term, 'prepend', addition) + + @pwhich.command(name='append') + async def pwhich_append(self, ctx, term: str, *, addition): + """Append the additional text to an existing which entry after a blank line.""" + await self._concatenate_which(ctx, term, 'append', addition) + + async def _concatenate_which(self, ctx, term: str, operation: str, addition): + dbcog = await self.get_dbcog() + + term = term.lower() + m = await dbcog.find_monster(term, ctx.author.id) + if m is None: + await ctx.send(f"No monster found for `{term}`") + return + + base_monster = dbcog.database.graph.get_base_monster(m) + if m != base_monster: + m = base_monster + await ctx.send("I think you meant {} for {}.".format(m.monster_no_na, m.name_en)) + mon_id = m.monster_id + + # ask for extra confirmation if the term was not an id + if not re.fullmatch(r'\d+', term): + if not await get_user_confirmation(ctx, + 'Are you sure you want to {} to the which info for {} [{}] {}?'.format( + operation, + get_attribute_emoji_by_monster(m), + m.monster_no_na, + m.name_en)): + return + + if mon_id not in self.settings.which(): + if await get_user_confirmation(ctx, + "No which info exists for {}. Would you like to add a new entry?".format( + m.name_en)): + self.settings.addWhich(mon_id, addition) + await ctx.send("PAD which info successfully {}.".format(bold('added'))) + return + + definition, _ = self.settings.which().get(mon_id, None) + + addition = clean_global_mentions(addition) + addition = addition.replace(u'\u200b', '') + addition = replace_emoji_names_with_code(self._get_emojis(), addition) + + if operation == 'prepend': + self.settings.addWhich(mon_id, '{}\n\n{}'.format(addition, definition)) + await ctx.send("Successfully {} to PAD which info for [{}] {}.".format(bold('prepended'), m.monster_no_na, + m.name_en)) + elif operation == 'append': + self.settings.addWhich(mon_id, '{}\n\n{}'.format(definition, addition)) + await ctx.send("Successfully {} to PAD which info for [{}] {}.".format(bold('appended'), m.monster_no_na, + m.name_en)) + else: + raise KeyError("Invalid operation: Must be \'prepend\' or \'append\'") + + @pwhich.command(name='dump') + async def pwhich_dump(self, ctx, *, term: str): + """Dump the raw text of an existing which entry, boxed.""" + _, definition, _, _ = await self._resolve_which(ctx, term) + + if definition is None: + return + else: + for page in pagify(definition): + content = box(page.replace('`', u'\u200b`')) + await ctx.send(content) + + @pwhich.command(name='list') + async def pwhich_list(self, ctx): + """List all which commands.""" + channel = '\N{WHITE HEAVY CHECK MARK}' + send_as_dm = '\N{ENVELOPE}' + cancel = '\N{CROSS MARK}' + destination = channel + if len(self.settings.which()) > MAX_WHICH_LIST_BEFORE_DM_PROMPT: + destination = await get_user_reaction(ctx, + 'This will send a lot of messages. Are you sure? ' + + '(Yes / DM me instead / Cancel)', + channel, + send_as_dm, + cancel) + if destination == cancel or destination is None: + return - @padglobal.command() - async def getwhich(self, ctx): - """Gets a list of all which commands.""" items = list() monsters = [] for w in self.settings.which(): w %= 10000 - nm = monster_id_to_named_monster(w) - name = nm.group_computed_basename.title() + m = (await self.get_dbcog()).get_monster(w) + name = m.name_en.split(', ')[-1] result = self.settings.which()[w] if isinstance(result, list): monsters.append([name, result[1]]) else: - monsters.append([name, "2000-01-01"]) + monsters.append([name, UNKNOWN_EDIT_TIMESTAMP]) tbl = prettytable.PrettyTable(['Monster', 'Timestamp']) tbl.hrules = prettytable.HEADER @@ -854,102 +1052,108 @@ async def getwhich(self, ctx): for mon in sorted(sorted(monsters), key=lambda x: x[1]): tbl.add_row(mon) - msg = rpadutils.strip_right_multiline(tbl.get_string()) + msg = strip_right_multiline(tbl.get_string()) for page in pagify(msg): - await ctx.send(box(page)) - - @padglobal.command() - async def debuglookup(self, ctx, *, term: str): - """Shows why a query matches to a monster""" - term = term.lower().replace('?', '') - nm, err, deb = lookup_named_monster(term) - base = nm.group_computed_basename.title() if nm else nm - name = nm.name_na if nm else nm - monster_id = nm.monster_id if nm else nm - definition = self.settings.which().get(monster_id, None) - await ctx.send('Which Debug:\n```Base: {}\nName: {}\nID: {}\nError: {}\nDebug: {}```' - .format(base, name, monster_id, err, deb)) + if destination == channel: + await ctx.send(box(page)) + else: + await ctx.author.send(box(page)) - @padglobal.command() + @padglobal.group() @checks.is_owner() - async def addadmin(self, ctx, user: discord.Member): - """Adds a user to the pad global admin""" - self.settings.addAdmin(user.id) - await ctx.send(inline("Done!")) + async def emojiservers(self, ctx): + """Emoji server subcommand""" - @padglobal.command() + @emojiservers.command(name="add") + @checks.is_owner() + async def es_add(self, ctx, server_id: int): + """Add the emoji server by ID""" + ess = self.settings.emojiServers() + if server_id not in ess: + ess.append(server_id) + self.settings.save_settings() + await ctx.tick() + + @emojiservers.command(name="remove", aliases=['rm', 'del']) @checks.is_owner() - async def rmadmin(self, ctx, user: discord.Member): - """Removes a user from the pad global admin""" - self.settings.rmAdmin(user.id) - await ctx.send(inline("Done")) + async def es_rm(self, ctx, server_id: int): + """Remove the emoji server by ID""" + ess = self.settings.emojiServers() + if server_id not in ess: + await ctx.send("That emoji server is not set.") + return + ess.remove(server_id) + self.settings.save_settings() + await ctx.tick() - @padglobal.command() + @emojiservers.command(name="list", aliases=['show']) @checks.is_owner() - async def setemojiservers(self, ctx, *, emoji_servers=''): - """Set the emoji servers by ID (csv)""" - self.settings.emojiServers().clear() - if emoji_servers: - self.settings.setEmojiServers(emoji_servers.split(',')) - await ctx.send(inline('Set {} servers'.format(len(self.settings.emojiServers())))) + async def es_show(self, ctx): + """List the emoji servers by ID""" + ess = self.settings.emojiServers() + await ctx.send(box("\n".join(str(s) for s in ess))) def _get_emojis(self): emojis = list() for server_id in self.settings.emojiServers(): try: emojis.extend(self.bot.get_guild(int(server_id)).emojis) - except: + except Exception: pass return emojis - @padglobal.command() - async def addemoji(self, ctx, monster_id: int, server: str = 'jp'): - """Create padglobal monster emoji by id. - - Uses jp monster IDs by default. You only need to change to na if you want to add - voltron or something. - - If you add a jp ID, it will look like ':pad_123:'. - If you add a na ID, it will look like ':pad_na_123:'. - """ - all_emoji_servers = self.settings.emojiServers() - if not all_emoji_servers: - await ctx.send('No emoji servers set') - return - - if server not in ['na', 'jp']: - await ctx.send('Server must be one of [jp, na]') - return - - if monster_id <= 0: - await ctx.send('Invalid monster id') - return - - server_ids = self.settings.emojiServers() - all_emojis = self._get_emojis() - - source_url = PORTRAIT_TEMPLATE.format(server, monster_id) - emoji_name = 'pad_' + ('na_' if server == 'na' else '') + str(monster_id) - - for e in all_emojis: - if emoji_name == e.name: - await ctx.send(inline('Already exists')) - return - - for server_id in server_ids: - emoji_server = self.bot.get_guild(int(server_id)) - if len(emoji_server.emojis) < 50: - break - - try: - async with aiohttp.ClientSession() as sess: - async with sess.get(source_url) as resp: - emoji_content = await resp.read() - await emoji_server.create_custom_emoji(name=emoji_name, image=emoji_content) - await ctx.send(inline('Done creating emoji named {}'.format(emoji_name))) - except Exception as ex: - await ctx.send(box('Error:\n' + str(ex))) + # temporarily removed since emoji servers are full + # @padglobal.command() + # async def addemoji(self, ctx, monster_id: int, server: str = 'jp'): + # """Create padglobal monster emoji by id. + + # Uses jp monster IDs by default. You only need to change to na if you want to add + # voltron or something. + + # If you add a jp ID, it will look like ':pad_123:'. + # If you add a na ID, it will look like ':pad_na_123:'. + # """ + # all_emoji_servers = self.settings.emojiServers() + # if not all_emoji_servers: + # await ctx.send('No emoji servers set') + # return + + # if server not in ['na', 'jp']: + # await ctx.send('Server must be one of [jp, na]') + # return + + # if monster_id <= 0: + # await ctx.send('Invalid monster id') + # return + + # server_ids = self.settings.emojiServers() + # all_emojis = self._get_emojis() + + # source_url = PORTRAIT_TEMPLATE.format(monster_id) + # emoji_name = 'pad_' + ('na_' if server == 'na' else '') + str(monster_id) + + # for e in all_emojis: + # if emoji_name == e.name: + # await ctx.send(inline('Already exists')) + # return + + # for server_id in server_ids: + # emoji_server = self.bot.get_guild(int(server_id)) + # if len(emoji_server.emojis) < 50: + # break + # else: + # await ctx.send("There is no room. Add a new emoji server to add more emoji.") + # return + + # try: + # async with aiohttp.ClientSession() as sess: + # async with sess.get(source_url) as resp: + # emoji_content = await resp.read() + # await emoji_server.create_custom_emoji(name=emoji_name, image=emoji_content) + # await ctx.send(inline('Done creating emoji named {}'.format(emoji_name))) + # except Exception as ex: + # await ctx.send(box('Error:\n' + str(ex))) @commands.Cog.listener('on_message') async def checkCC(self, message): @@ -985,7 +1189,8 @@ async def checkCC(self, message): cmd = self.format_cc(result, message) - await message.channel.send(cmd) + for page in pagify(cmd): + await message.channel.send(page) def _lookup_command(self, cmd): """Returns the corrected cmd name. @@ -1025,14 +1230,14 @@ async def get_prefix(self, message): for p in await self.bot.get_prefix(message): if message.content.startswith(p): return p - return False + return None def format_cc(self, command, message): - results = re.findall(r"\{([^}]+)\}", command) + results = re.findall(r"{([^}]+)}", command) for result in results: param = self.transform_parameter(result, message) command = command.replace("{" + result + "}", param) - return command + return self.emojify(command) def transform_parameter(self, result, message): """ @@ -1063,48 +1268,50 @@ async def guide(self, ctx, *, term: str = None): await self.send_guide(ctx) return - term, text, err = self.get_guide_text(term) + term, text, err = await self.get_guide_text(term, ctx) if text is None: await ctx.send(inline(err)) return - await ctx.send(text) + await ctx.send(self.emojify(text)) + + async def get_guide_text(self, term: str, ctx): + dbcog = await self.get_dbcog() - def get_guide_text(self, term: str): term = term.lower() if term in self.settings.dungeonGuide(): return term, self.settings.dungeonGuide()[term], None - nm, _, _ = lookup_named_monster(term) - if nm is None: + m = await dbcog.find_monster(term, ctx.author.id) + if m is None: return None, None, 'No dungeon or monster matched that query' + m = dbcog.database.graph.get_base_monster(m) - name = nm.group_computed_basename.title() - definition = self.settings.leaderGuide().get(str(nm.base_monster_no), None) + name = m.name_en + definition = self.settings.leaderGuide().get(m.monster_id, None) if definition is None: return None, None, 'A monster matched that query but has no guide' return name, definition, None async def send_guide(self, ctx): - msg = self.guide_to_text() + msg = await self.guide_to_text() for page in pagify(msg): await ctx.author.send(page) - def guide_to_text(self): + async def guide_to_text(self): msg = '__**Dungeon Guides**__' dungeon_guide = self.settings.dungeonGuide() for term in sorted(dungeon_guide.keys()): - definition = dungeon_guide[term] - msg += '\n**{}** :\n{}\n'.format(term, definition) + msg += '\n{}'.format(term) msg += '\n\n__**Leader Guides**__' for monster_id, definition in self.settings.leaderGuide().items(): - nm = monster_id_to_named_monster(monster_id) - if nm is None: + m = (await self.get_dbcog()).get_monster(monster_id) + if m is None: continue - name = nm.group_computed_basename.title() - msg += '\n**{}** :\n{}\n'.format(name, definition) + name = m.name_en.split(', ')[-1].title() + msg += '\n[{}] {}'.format(monster_id, name) return msg @@ -1115,7 +1322,7 @@ async def guideto(self, ctx, to_user: discord.Member, *, term: str): [p]guideto @{0.author.name} osc10 """ - term, text, err = self.get_guide_text(term) + term, text, err = await self.get_guide_text(term, ctx) if text is None: await ctx.send(inline(err)) return @@ -1123,49 +1330,98 @@ async def guideto(self, ctx, to_user: discord.Member, *, term: str): result_output = '**Guide for {}**\n{}'.format(term, text) result = "{} asked me to send you this:\n{}".format( ctx.author.name, result_output) - await to_user.send_message(result) + await to_user.send(result) msg = "Sent guide for {} to {}".format(term, to_user.name) await ctx.send(inline(msg)) - @padglobal.command() - async def adddungeonguide(self, ctx, term: str, *, definition: str): + @commands.group() + @auth_check('contentadmin') + async def pguide(self, ctx): + """Commands related to PAD global dungeon and leader guides.""" + + @pguide.group() + async def dungeon(self, ctx): + """Dungeon guide subcommands.""" + + @dungeon.command(name='add') + async def dungeon_add(self, ctx, term: str, *, definition: str): """Adds a dungeon guide to the [p]guide command""" + await self._dungeon_add(ctx, term, definition, True) + + @dungeon.command(name='edit') + async def dungeon_edit(self, ctx, term: str, *, definition: str): + """Edit a dungeon guide from the [p]guide command.""" + await self._dungeon_add(ctx, term, definition, False) + + async def _dungeon_add(self, ctx, term: str, definition: str, need_confirm=True): term = term.lower() - op = 'EDITED' if term in self.settings.dungeonGuide() else 'ADDED' + op = 'edited' if term in self.settings.dungeonGuide() else 'added' + if op == 'edited' and need_confirm: + if not await get_user_confirmation(ctx, + "Are you sure you want to edit the dungeon guide info for {}?".format( + term)): + return self.settings.addDungeonGuide(term, definition) - await ctx.send("PAD dungeon guide successfully {}.".format(op)) + await ctx.send("PAD dungeon guide successfully {}.".format(bold(op))) - @padglobal.command() - async def rmdungeonguide(self, ctx, term: str): + @dungeon.command(name='remove', aliases=['rm', 'delete', 'del']) + async def dungeon_remove(self, ctx, term: str): """Removes a dungeon guide from the [p]guide command""" term = term.lower() if term not in self.settings.dungeonGuide(): await ctx.send("DungeonGuide doesn't exist.") return - + if not await get_user_confirmation(ctx, + "Are you sure you want to globally remove the dungeonguide data for {}?".format( + term)): + return self.settings.rmDungeonGuide(term) - await ctx.send("done") + await ctx.tick() - @padglobal.command() - async def addleaderguide(self, ctx, monster_id: int, *, definition: str): + @pguide.group() + async def leader(self, ctx): + """Leader guide subcommands.""" + + @leader.command(name='add') + async def leader_add(self, ctx, monster_id: int, *, definition: str): """Adds a leader guide to the [p]guide command""" - m = monster_id_to_monster(monster_id) - if m != m.base_monster: - m = m.base_monster - await ctx.send("I think you meant {} for {}.".format(m.monster_no_na, m.name_na)) + await self._leader_add(ctx, monster_id, definition, True) + + @leader.command(name='edit') + async def leader_edit(self, ctx, monster_id: int, *, definition: str): + """Edit a leader guide from the [p]guide command.""" + await self._leader_add(ctx, monster_id, definition, False) + + async def _leader_add(self, ctx, monster_id: int, definition: str, need_confirm=True): + m = (await self.get_dbcog()).get_monster(monster_id) + base_monster = (await self.get_dbcog()).database.graph.get_base_monster(m) + if m != base_monster: + m = base_monster + await ctx.send("I think you meant {} for {}.".format(m.monster_no_na, m.name_en)) name = m.monster_id - op = 'EDITED' if name in self.settings.leaderGuide() else 'ADDED' + op = 'edited' if name in self.settings.leaderGuide() else 'added' + if op == 'edited' and need_confirm: + if not await get_user_confirmation(ctx, + "Are you sure you want to edit the leader guide for {}?".format( + m.name_en)): + return self.settings.addLeaderGuide(name, definition) - await ctx.send("PAD leader guide info successfully {}.".format(op)) + await ctx.send("PAD leader guide info successfully {}.".format(bold(op))) - @padglobal.command() - async def rmleaderguide(self, ctx, monster_id: int): + @leader.command(name='remove', aliases=['rm', 'delete', 'del']) + async def leader_remove(self, ctx, monster_id: int): """Removes a leader guide from the [p]guide command""" - m = monster_id_to_monster(monster_id) - if m != m.base_monster: - m = m.base_monster - await ctx.send("I think you meant {} for {}.".format(m.monster_no_na, m.name_na)) + dbcog = await self.get_dbcog() + m = dbcog.get_monster(monster_id) + base_monster = dbcog.database.graph.get_base_monster(m) + if m != base_monster: + m = base_monster + await ctx.send("I think you meant {} for {}.".format(m.monster_no_na, m.name_en)) + if not await get_user_confirmation(ctx, + "Are you sure you want to globally remove the leaderguide data for {}?".format( + m.name_en)): + return name = m.monster_id if name not in self.settings.leaderGuide(): @@ -1173,23 +1429,95 @@ async def rmleaderguide(self, ctx, monster_id: int): return self.settings.rmLeaderGuide(name) - await ctx.send("done") - - def term_to_monster_name(self, term): - nm, _, _ = lookup_named_monster(term) - return nm.group_computed_basename.title() + await ctx.tick() + + @commands.command(aliases=['currentinvade']) + async def whichinvade(self, ctx): + """Display which yinyangdra is currently invading for Mystics & Spectres event""" + curtime = datetime.datetime.now(NA_TIMEZONE) + if datetime.time(6) < curtime.time() < datetime.time(18): + await ctx.send(self.c_commands['redinvadecurrent']) + totime = curtime.replace(hour=18, minute=0, second=0, microsecond=0) + else: + await ctx.send(self.c_commands['blueinvadecurrent']) + totime = curtime.replace(hour=6, minute=0, second=0, microsecond=0) + if totime < curtime: + totime += datetime.timedelta(1) + await ctx.send(inline("Invade switches in: " + humanize_timedelta(timedelta=totime - curtime))) + + @commands.command(aliases=['resettime', 'newday', 'whenreset']) + async def daychange(self, ctx): + """Show DST information and how much time is left until the game day changes.""" + curtime = datetime.datetime.now(pytz.timezone("UTC")) + reset = curtime.replace(hour=8, minute=0, second=0, microsecond=0) + if reset < curtime: + reset += datetime.timedelta(1) + resetdelta = reset - curtime + # strip leftover seconds + resetdelta -= datetime.timedelta(seconds=resetdelta.total_seconds() % 60) + totalresetmins = int(resetdelta.total_seconds() // 60) + resethours = totalresetmins // 60 + newdayhours = resethours + 4 + mins = totalresetmins % 60 + + pst = datetime.datetime.now(pytz.timezone("America/Los_Angeles")) + if pst.dst(): + # earliest possible date of the first Sunday in November + dstthresh = pst.replace(month=11, day=1) + else: + # earliest possible date of the second Sunday in March + dstthresh = pst.replace(month=3, day=8) + + # calculate the day DST changes + if dstthresh < pst: + dstthresh = dstthresh.replace(year=(dstthresh.year + 1)) + # add days to make day of week equal 6 (Sunday, when Monday is 0) + dstthresh += datetime.timedelta(6 - dstthresh.weekday()) + + msg = "Reset (dungeons/events): **{}h {}m** ".format(resethours, mins) + msg += "(1:00 am PDT)" if pst.dst() else "(12:00 midnight PST)" + msg += ".\nNew day (mails): **{}h {}m** ".format(newdayhours, mins) + msg += "(5:00 am PDT)" if pst.dst() else "(4:00 am PST)" + msg += ".\nDST in North America is " + msg += "ACTIVE" if pst.dst() else "NOT ACTIVE" + msg += "! It will " + msg += "end" if pst.dst() else "start" + msg += " in " + humanize_timedelta(timedelta=dstthresh - pst) + "." + + await ctx.send(msg) + + async def get_dbcog(self): + dbcog = self.bot.get_cog("DBCog") + await dbcog.wait_until_ready() + return dbcog + + def emojify(self, message): + emojis = list() + emoteservers = self.settings.emojiServers() + for guild in emoteservers: + if self.bot.get_guild(int(guild)): + emojis.extend(self.bot.get_guild(int(guild)).emojis) + for guild in self.bot.guilds: + if guild.id in emoteservers: + continue + emojis.extend(guild.emojis) + message = replace_emoji_names_with_code(emojis, message) + return fix_emojis_for_server(emojis, message) -def check_simple_tree(monster): +def check_simple_tree(monster, db_context): attr1 = monster.attr1 active_skill = monster.active_skill - for m in monster.alt_evos: - if m.attr1 != attr1 or m.active_skill != active_skill: + for m in (evo_tree := db_context.graph.get_evo_tree(monster)): + if m.attr1 != attr1 or m.active_skill.active_skill_id != active_skill.active_skill_id: return False if m.is_equip: return False - if 'awoken' in m.name_na.lower(): + if len(db_context.graph.get_next_evolutions(m)) > 1: return False + # every evo has super awakenings (can limit break) + if len(evo_tree) > 1 and all(m.superawakening_count for m in evo_tree): + return False return True diff --git a/padglobal/view/which.py b/padglobal/view/which.py new file mode 100644 index 00000000..cddff73b --- /dev/null +++ b/padglobal/view/which.py @@ -0,0 +1,86 @@ +from discordmenu.embed.base import Box +from discordmenu.embed.components import EmbedMain, EmbedField +from discordmenu.embed.text import Text +from discordmenu.embed.view import EmbedView +from discordmenu.emoji.emoji_cache import emoji_cache +from tsutils.menu.components.footers import embed_footer_with_state +from tsutils.menu.view.closable_embed import ClosableEmbedViewState + +STAR = '* ' +BULLET_EMOJIS = ['db', 'dp', 'dg'] +UNKNOWN_EDIT_TIMESTAMP = '1970-01-01' + + +class WhichViewProps: + def __init__(self, name: str, definition: str, timestamp: str, success: bool): + self.name = name + self.definition = definition + self.timestamp = timestamp + self.success = success + + +def _get_field(paragraph, color): + lines = paragraph.strip().splitlines() + lines = [_replace_bullets(line.strip(), color) for line in lines] + + if len(lines) > 1: + evo = lines[0] + information = '\n'.join(lines[1:]) + if evo.startswith('**'): + return EmbedField(evo, Box(information)) + + # it wasn't multiple lines that started with a bolded line, assume it's not an evo description + return EmbedField('**Additional Information**', Box(paragraph)) + + +def _replace_bullets(line, color_index): + if line.startswith(STAR): + bullet = emoji_cache.get_emoji(BULLET_EMOJIS[color_index]) + return '{} {}'.format(bullet, line[len(STAR):]) + else: + return line + + +def get_description(definition, timestamp, success): + if success: + if timestamp == UNKNOWN_EDIT_TIMESTAMP: + return Text('No last-updated date recorded') + else: + return Text('Last Updated {}'.format(timestamp)) + else: + return definition + + +def get_fields(definition): + paragraphs = definition.split('\n\n') + color = 0 + fields = [] + + for paragraph in paragraphs: + fields.append(_get_field(paragraph, color)) + color = ((color + 1) % len(BULLET_EMOJIS)) + + return fields + + +class WhichView: + VIEW_TYPE = 'Which' + + @staticmethod + def embed(state: ClosableEmbedViewState, props: WhichViewProps): + name = props.name + definition = props.definition + timestamp = props.timestamp + success = props.success + + fields = get_fields(definition) if success else [] + + return EmbedView( + EmbedMain( + color=state.query_settings.embedcolor, + title='Which {}'.format(name), + description=get_description(definition, timestamp, success) + ), + embed_footer=embed_footer_with_state(state), + embed_fields=fields + ) diff --git a/padguidedb/__init__.py b/padguidedb/__init__.py deleted file mode 100644 index b11e1b1a..00000000 --- a/padguidedb/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from .padguidedb import * - - -def setup(bot): - # TODO: Test this! I don't have the databases - pdb = PadGuideDb(bot) - bot.add_cog(pdb) diff --git a/padguidedb/padguidedb.py b/padguidedb/padguidedb.py deleted file mode 100644 index c17a5ad6..00000000 --- a/padguidedb/padguidedb.py +++ /dev/null @@ -1,315 +0,0 @@ -import asyncio -import csv -import io -import json -import re - -import discord -import pymysql -from redbot.core import checks -from redbot.core import commands -from redbot.core.bot import Red -from redbot.core.utils.chat_formatting import inline, box, pagify - -from rpadutils import CogSettings, rpadutils - -PADGUIDEDB_COG = None - - -def is_padguidedb_admin_check(ctx): - is_owner = PADGUIDEDB_COG.bot.owner_id == ctx.author.id - return is_owner or PADGUIDEDB_COG.settings.checkAdmin(ctx.author.id) - - -def is_padguidedb_admin(): - return commands.check(is_padguidedb_admin_check) - - -class PadGuideDb(commands.Cog): - """PadGuide Database manipulator""" - - def __init__(self, bot: Red, *args, **kwargs): - super().__init__(*args, **kwargs) - self.bot = bot - self.settings = PadGuideDbSettings("padguidedb") - - self.queue_size = 0 - self.full_etl_lock = asyncio.Lock() - self.extract_images_lock = asyncio.Lock() - self.dungeon_load_lock = asyncio.Lock() - global PADGUIDEDB_COG - PADGUIDEDB_COG = self - - def get_connection(self): - with open(self.settings.configFile(), 'r') as db_config: - return self.connect(json.load(db_config)) - - def connect(self, db_config): - return pymysql.connect(host=db_config['host'], - user=db_config['user'], - password=db_config['password'], - db=db_config['db'], - charset=db_config['charset'], - cursorclass=pymysql.cursors.DictCursor, - autocommit=True) - - @commands.group(aliases=['pdb']) - @is_padguidedb_admin() - async def padguidedb(self, context): - """PadGuide database manipulation.""" - - @padguidedb.command() - @checks.is_owner() - async def addadmin(self, ctx, user: discord.Member): - """Adds a user to the padguide db admin""" - self.settings.addAdmin(user.id) - await ctx.send("done") - - @padguidedb.command() - @checks.is_owner() - async def rmadmin(self, ctx, user: discord.Member): - """Removes a user from the padguide db admin""" - self.settings.rmAdmin(user.id) - await ctx.send("done") - - @padguidedb.command() - @checks.is_owner() - async def setconfigfile(self, ctx, *, config_file): - """Set the database config file.""" - self.settings.setConfigFile(config_file) - await ctx.send(inline('Done')) - - @padguidedb.command() - @is_padguidedb_admin() - async def searchdungeon(self, ctx, *, search_text): - """Search""" - search_text = re.sub(r"[@#\$%\^&\*;\/<>?\\|`~\-\=]", " ", search_text) - with self.get_connection() as cursor: - sql = ("select dungeon_id, name_na, name_jp, visible from dungeons" - " where (lower(name_na) like '%{}%' or lower(name_jp) like '%{}%')" - " order by dungeon_id desc limit 20".format(search_text, search_text)) - cursor.execute(sql) - results = list(cursor.fetchall()) - msg = 'Results\n' + json.dumps(results, indent=2, ensure_ascii=False) - await ctx.send(inline(sql)) - for page in pagify(msg): - await ctx.send(box(page)) - - @padguidedb.command() - @checks.is_owner() - async def setdungeonscriptfile(self, ctx, *, dungeon_script_file): - """Set the dungeon script.""" - self.settings.setDungeonScriptFile(dungeon_script_file) - await ctx.send(inline('Done')) - - @padguidedb.command() - @checks.is_owner() - async def setuserinfo(self, ctx, server: str, user_uuid: str, user_intid: str): - """Set the dungeon script.""" - self.settings.setUserInfo(server, user_uuid, user_intid) - await ctx.send(inline('Done')) - - @padguidedb.command() - @is_padguidedb_admin() - async def loaddungeon(self, ctx, server: str, dungeon_id: int, dungeon_floor_id: int, queues: int = 1): - if queues > 5: - await ctx.send("You must send less than 5 queues.") - return - elif self.queue_size + queues > 60: - await ctx.send("The size of the queue cannot exceed 60. It is currently {}.".format(self.queue_size)) - return - elif not self.settings.hasUserInfo(server): - await ctx.send("There is no account associated with server '{}'.".format(server.upper())) - return - - self.queue_size += queues - if queues == 1: - await ctx.send(inline('Queueing load in slot {}'.format(self.queue_size))) - else: - await ctx.send( - inline('Queueing loads in slots {}-{}'.format(self.queue_size - queues + 1, self.queue_size))) - - event_loop = asyncio.get_event_loop() - for queue in range(queues): - event_loop.create_task(self.do_dungeon_load(ctx, server.upper(), dungeon_id, dungeon_floor_id)) - - async def do_dungeon_load(self, ctx, server, dungeon_id, dungeon_floor_id): - async with self.dungeon_load_lock: - process = await asyncio.create_subprocess_exec( - '/usr/bin/python3', - self.settings.dungeonScriptFile(), - '--db_config={}'.format(self.settings.configFile()), - '--server={}'.format(server), - '--dungeon_id={}'.format(dungeon_id), - '--floor_id={}'.format(dungeon_floor_id), - '--user_uuid={}'.format(self.settings.userUuidFor(server)), - '--user_intid={}'.format(self.settings.userIntidFor(server)), - - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout, stderr = await process.communicate() - - if stderr: - print("Dungeon Load Error:\n" + stderr.decode()) - await rpadutils.doubleup(ctx, inline( - 'Load for {} {} {} failed'.format(server, dungeon_id, dungeon_floor_id))) - else: - await rpadutils.doubleup(ctx, inline( - 'Load for {} {} {} finished'.format(server, dungeon_id, dungeon_floor_id))) - self.queue_size -= 1 - - @padguidedb.command() - @is_padguidedb_admin() - async def dungeondrops(self, ctx, dungeon_id: int, dungeon_floor_id: int): - with self.get_connection() as cursor: - sql = ("SELECT stage, drop_monster_id, COUNT(*) AS count" - " FROM wave_data" - " WHERE dungeon_id = {} AND floor_id = {}" - " GROUP BY 1, 2" - " ORDER BY 1, 2".format(dungeon_id, dungeon_floor_id)) - cursor.execute(sql) - results = list(cursor.fetchall()) - msg = 'stage,drop_monster_id,count' - for row in results: - msg += '\n{},{},{}'.format(row['stage'], row['drop_monster_id'], row['count']) - for page in pagify(msg): - await ctx.send(box(page)) - - @padguidedb.command() - @checks.is_owner() - async def cleardungeon(self, ctx, dungeon_id: int): - with self.get_connection() as cursor: - sql = "DELETE FROM wave_data WHERE dungeon_id = {}".format(dungeon_id) - cursor.execute(sql) - await ctx.send(inline("Done")) - - # @padguidedb.command() - @is_padguidedb_admin() - async def dungeondata(self, ctx, dungeon_id: int): - with ctx.typing(), self.get_connection() as cursor: - sql = "SELECT * FROM wave_data WHERE dungeon_id = {}".format(dungeon_id) - cursor.execute(sql) - results = list(cursor.fetchall()) - order = sorted(results[0]) - rows = [list(map(lambda x: row[x], order)) for row in results] - fauxfile = io.StringIO() - writer = csv.writer(fauxfile) - writer.writerow(order) - writer.writerows(rows) - fauxfile.seek(0) - m = await ctx.send(file=discord.File(fauxfile, filename="dungeondata.csv")) - await asyncio.sleep(10) - await m.delete() - - @padguidedb.group() - @is_padguidedb_admin() - async def pipeline(self, context): - """PadGuide pipeline utilities.""" - - @pipeline.command() - @is_padguidedb_admin() - async def fulletl(self, ctx): - """Runs a job which downloads pad data, and updates the padguide database.""" - if self.full_etl_lock.locked(): - await ctx.send(inline('Full ETL already running')) - return - - async with self.full_etl_lock: - await ctx.send(inline('Running full ETL pipeline: this could take a while')) - process = await asyncio.create_subprocess_exec( - 'bash', - '/home/tactical0retreat/dadguide/dadguide-jobs/run_loader.sh', - - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout, stderr = await process.communicate() - - if stderr: - print("Full ETL Error:\n" + stderr.decode()) - await ctx.send(inline('Full ETL failed')) - return - await ctx.send(inline('Full ETL finished')) - - @pipeline.command() - @is_padguidedb_admin() - async def extractimages(self, ctx): - """Runs a job which downloads image updates, generates full images, and portraits.""" - if self.extract_images_lock.locked(): - await ctx.send(inline('Extract images already running')) - return - - async with self.extract_images_lock: - await ctx.send(inline('Running image extract pipeline: this could take a while')) - process = await asyncio.create_subprocess_exec( - 'bash', - '/home/tactical0retreat/dadguide/dadguide-jobs/media/update_image_files.sh', - - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout, stderr = await process.communicate() - - if stderr: - print("Image Extract Error:\n" + stderr.decode()) - await ctx.send(inline('Image extract failed')) - return - await ctx.send(inline('Image extract finished')) - - -class PadGuideDbSettings(CogSettings): - def make_default_settings(self): - config = { - 'admins': [], - 'config_file': '', - 'dungeon_script_file': '', - 'users': {} - } - return config - - def admins(self): - return self.bot_settings['admins'] - - def checkAdmin(self, user_id): - admins = self.admins() - return user_id in admins - - def addAdmin(self, user_id): - admins = self.admins() - if user_id not in admins: - admins.append(user_id) - self.save_settings() - - def rmAdmin(self, user_id): - admins = self.admins() - if user_id in admins: - admins.remove(user_id) - self.save_settings() - - def configFile(self): - return self.bot_settings.get('config_file', '') - - def setConfigFile(self, config_file): - self.bot_settings['config_file'] = config_file - self.save_settings() - - def dungeonScriptFile(self): - return self.bot_settings.get('dungeon_script_file', '') - - def setDungeonScriptFile(self, dungeon_script_file): - self.bot_settings['dungeon_script_file'] = dungeon_script_file - self.save_settings() - - def setUserInfo(self, server, user_uuid, user_intid): - self.bot_settings['users'][server.upper()] = [user_uuid, user_intid] - self.save_settings() - - def hasUserInfo(self, server): - return server.upper() in self.bot_settings['users'] - - def userUuidFor(self, server): - return self.bot_settings['users'][server.upper()][0] - - def userIntidFor(self, server): - return self.bot_settings['users'][server.upper()][1] diff --git a/padinfo/__init__.py b/padinfo/__init__.py index b35067a7..c0b9c44a 100644 --- a/padinfo/__init__.py +++ b/padinfo/__init__.py @@ -1,7 +1,15 @@ -from .padinfo import * +import json +from pathlib import Path +from .padinfo import PadInfo -def setup(bot): +with open(Path(__file__).parent / "info.json") as file: + __red_end_user_data_statement__ = json.load(file)['end_user_data_statement'] + + +async def setup(bot): n = PadInfo(bot) - bot.add_cog(n) + bot.add_cog(n) if not __import__('asyncio').iscoroutinefunction(bot.add_cog) else await bot.add_cog(n) + bot.loop.create_task(n.register_menu()) bot.loop.create_task(n.reload_nicknames()) + bot.loop.create_task(n.load_aliases()) diff --git a/padinfo/board_generator.py b/padinfo/board_generator.py new file mode 100644 index 00000000..cfe0568a --- /dev/null +++ b/padinfo/board_generator.py @@ -0,0 +1,51 @@ +class BoardGenerator(object): + """Generate a Dawnglare link with optional inversion.""" + + allowed_letters = "RGBLDHXJMPZ" + inversion = "-1" + dawnglare_link = "https://pad.dawnglare.com/?patt={}&showfill=1" + board_sizes = { + 20: {"height": 4, "width": 5}, + 30: {"height": 5, "width": 6}, + 42: {"height": 6, "width": 7} + } + + def __init__(self, board): + self.board = board.replace(" ", "") + self.invert = self.inversion in self.board + self.board = self.board.replace(self.inversion, "") + self.board_length = len(self.board) + self.invalid_size = self.board_length not in self.board_sizes + self.invalid_orbs = not all(letter in self.allowed_letters for letter in self.board) + + if self.invalid_size or self.invalid_orbs: + return + + self.board_height = self.board_sizes[self.board_length].get('height') + self.board_width = self.board_sizes[self.board_length].get('width') + + if self.invert: + self.board = self.invert_fill(self.board) + self.link = self.get_link() + + def invert_fill(self, board): + inverted_board = "" + k = 0 + + for i in range(self.board_height): + k = i + build_string = "" + for j in range(self.board_width): + build_string += board[k] + k += self.board_height + inverted_board += build_string + + return inverted_board + + def get_link(self): + link = self.dawnglare_link.format(self.board) + + if self.board_length == 20 or self.board_length == 42: + link = f"{link}&height={self.board_height}&width={self.board_width}" + + return link diff --git a/padinfo/core/__init__.py b/padinfo/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padinfo/core/button_info.py b/padinfo/core/button_info.py new file mode 100644 index 00000000..f4602e5d --- /dev/null +++ b/padinfo/core/button_info.py @@ -0,0 +1,248 @@ +from collections import namedtuple +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from dbcog.models.monster_model import MonsterModel + +LIMIT_BREAK_LEVEL = 110 +SUPER_LIMIT_BREAK_LEVEL = 120 + +INFO_STRING = """[{}] {} +(Co-op mode) + +Without Latents: +Base: {} +Subattr: {} +Total: {} + +With Latents: +Base: {} +Subattr: {} +Total: {} + +-------------- +Inherits are assumed to be the max possible level (up to 110) and +297 +{} +{} +""" + +TEAM_BUTTON_FORMAT = "[{}] {} ({} {}): {}" +CARD_BUTTON_FORMAT = "[{}] {} ({}): {}" + +MonsterStat = namedtuple('MonsterStat', 'name id mult att') + +TEAM_BUTTONS = [ + # account for attributes when spacing + MonsterStat("Nergi Hunter{}", 4172, 40, [2, 4]), + MonsterStat("Oversoul{} ", 5273, 50, [0, 1, 2, 3, 4]), + MonsterStat("Ryuno Ume{} ", 5252, 80, [2, 4]), + MonsterStat("Chu{} ", 4316, 60, [1, 2]) +] +CARD_BUTTONS = [ + MonsterStat("Assassin{} ", 5021, 200, []), + MonsterStat("Satan{} ", 4286, 300, []), + MonsterStat("Durandalf Eq{} ", 4723, 300, []), + MonsterStat("Brachydios Eq{}", 4152, 350, []), + MonsterStat("Rajang Eq{} ", 5530, 550, []), + MonsterStat("Balrog{} ", 5108, 450, []) +] + +COLORS = ["R", "B", "G", "L", "D"] +NIL_ATT = 'Nil' + + +def _get_sub_attr_multiplier(monster: "MonsterModel"): + if monster.attr2.value == 6 or monster.attr1.value == 6: + return 0 + if monster.attr2.value == monster.attr1.value: + return 1 / 10 + return 1 / 3 + + +class ButtonInfo: + def get_info(self, dbcog, monster): + """ + Usage: ^buttoninfo Vajrayasaka + + Shows (main, sub, main+sub)damage x (with | without) atk++ latents + + Optional arguments include: + + [coop|solo], default coop + e.g: + + button dmg: 7668 + with subattr: 9968.4 + just subattr: whatever + + button dmg with atklatent: 8454.51 + with subattr: 10990.863 + just subattr: whatever + """ + max_level = LIMIT_BREAK_LEVEL if monster.limit_mult != 0 else monster.level + slb_level = SUPER_LIMIT_BREAK_LEVEL if monster.limit_mult != 0 else None + max_atk_latents = monster.latent_slots / 2 + + result = ButtonInfoResult() + result.coop = ButtonInfoStatSet(monster, self._calculate_coop_damage(dbcog, monster, max_level)) + result.solo = ButtonInfoStatSet(monster, self._calculate_solo_damage(dbcog, monster, max_level)) + result.coop_latent = ButtonInfoStatSet( + monster, self._calculate_coop_damage(dbcog, monster, max_level, num_atkplus_latent=max_atk_latents)) + result.solo_latent = ButtonInfoStatSet( + monster, self._calculate_solo_damage(dbcog, monster, max_level, num_atkplus_latent=max_atk_latents)) + + if slb_level is None: + result.coop_slb = ButtonInfoStatSet(monster) + result.solo_slb = ButtonInfoStatSet(monster) + result.coop_slb_latent = ButtonInfoStatSet(monster) + result.solo_slb_latent = ButtonInfoStatSet(monster) + else: + result.coop_slb = ButtonInfoStatSet(monster, self._calculate_coop_damage(dbcog, monster, slb_level)) + result.solo_slb = ButtonInfoStatSet(monster, self._calculate_solo_damage(dbcog, monster, slb_level)) + result.coop_slb_latent = ButtonInfoStatSet( + monster, self._calculate_coop_damage(dbcog, monster, slb_level, num_atkplus2_latent=max_atk_latents)) + result.solo_slb_latent = ButtonInfoStatSet( + monster, self._calculate_solo_damage(dbcog, monster, slb_level, num_atkplus2_latent=max_atk_latents)) + + result.card_btn_str = self._get_card_text(CARD_BUTTONS, dbcog, monster, + multiplayer=True, limit_break=LIMIT_BREAK_LEVEL) + result.card_btn_solo_str = self._get_card_text(CARD_BUTTONS, dbcog, monster, + multiplayer=False, limit_break=LIMIT_BREAK_LEVEL) + result.card_btn_slb_str = self._get_card_text(CARD_BUTTONS, dbcog, monster, + multiplayer=True, limit_break=SUPER_LIMIT_BREAK_LEVEL) + result.card_btn_solo_slb_str = self._get_card_text(CARD_BUTTONS, dbcog, monster, + multiplayer=False, limit_break=SUPER_LIMIT_BREAK_LEVEL) + + result.team_btn_str = self._get_team_text(TEAM_BUTTONS, dbcog, monster, + multiplayer=True, limit_break=LIMIT_BREAK_LEVEL) + result.team_btn_solo_str = self._get_team_text(TEAM_BUTTONS, dbcog, monster, + multiplayer=False, limit_break=LIMIT_BREAK_LEVEL) + result.team_btn_slb_str = self._get_team_text(TEAM_BUTTONS, dbcog, monster, + multiplayer=True, limit_break=SUPER_LIMIT_BREAK_LEVEL) + result.team_btn_solo_slb_str = self._get_team_text(TEAM_BUTTONS, dbcog, monster, + multiplayer=False, limit_break=SUPER_LIMIT_BREAK_LEVEL) + return result + + def _calculate_coop_damage(self, dbcog, monster, level, num_atkplus_latent=0, num_atkplus2_latent=0): + return self._calculate_damage(dbcog, monster, level, True, num_atkplus_latent, num_atkplus2_latent) + + def _calculate_solo_damage(self, dbcog, monster, level, num_atkplus_latent=0, num_atkplus2_latent=0): + return self._calculate_damage(dbcog, monster, level, False, num_atkplus_latent, num_atkplus2_latent) + + @staticmethod + def _calculate_damage(dbcog, monster, level, multiplayer, num_atkplus_latent=0, num_atkplus2_latent=0): + stat_latents = dbcog.MonsterStatModifierInput(num_atkplus=num_atkplus_latent, num_atkplus2=num_atkplus2_latent) + stat_latents.num_atk_awakening = len([x for x in monster.awakenings if x.awoken_skill_id == 1]) + + dmg = dbcog.monster_stats.stat(monster, 'atk', level, stat_latents=stat_latents, multiplayer=multiplayer) + + return int(round(dmg)) + + @staticmethod + def _get_card_text(card_buttons, dbcog, monster: "MonsterModel", multiplayer, limit_break): + lines = [] + card_buttons.sort(key=lambda x: x.mult) + for card in card_buttons: + inherit_model = dbcog.get_monster(card.id, server=monster.server_priority) + max_level = limit_break if monster.limit_mult != 0 else monster.level + inherit_max_level = LIMIT_BREAK_LEVEL if inherit_model.limit_mult != 0 else inherit_model.level + + num_latent_slots = monster.latent_slots / 2 + num_atkplus = num_latent_slots + num_atkplus2 = 0 + if limit_break == SUPER_LIMIT_BREAK_LEVEL and monster.limit_mult != 0: + num_atkplus = 0 + num_atkplus2 = num_latent_slots + + stat_latents = dbcog.MonsterStatModifierInput(num_atkplus=num_atkplus, num_atkplus2=num_atkplus2) + dmg = int(round(dbcog.monster_stats.stat(monster, 'atk', max_level, stat_latents=stat_latents, + inherited_monster=inherit_model, multiplayer=multiplayer, + inherited_monster_lvl=inherit_max_level))) + oncolor = '*' if monster.attr1.value == inherit_model.attr1.value or monster.attr1.name == NIL_ATT else ' ' + lines.append(CARD_BUTTON_FORMAT.format( + card.id, card.name.format(oncolor), card.mult, round(dmg * card.mult, 2))) + return "\n".join(lines) + + @staticmethod + def _get_team_text(team_buttons, dbcog, monster, *, multiplayer, limit_break): + lines = [] + team_buttons.sort(key=lambda x: x.mult) + for card in team_buttons: + total_dmg = 0 + inherit_model = dbcog.get_monster(card.id, server=monster.server_priority) + max_level = limit_break if monster.limit_mult != 0 else monster.level + inherit_max_level = LIMIT_BREAK_LEVEL if inherit_model.limit_mult != 0 else inherit_model.level + stat_latents = dbcog.MonsterStatModifierInput(num_atkplus=monster.latent_slots / 2) + dmg = dbcog.monster_stats.stat(monster, 'atk', max_level, stat_latents=stat_latents, + inherited_monster=inherit_model, multiplayer=multiplayer, + inherited_monster_lvl=inherit_max_level) + # do count null main att monster damage as if it were the main att + if monster.attr1.value in card.att or monster.attr1.name == NIL_ATT: + total_dmg += dmg + # do not double-count null main att + if monster.attr2.value in card.att and monster.attr1.name != NIL_ATT: + total_dmg += dmg * _get_sub_attr_multiplier(monster) + colors_str = "" + for i in card.att: + colors_str += COLORS[i] + oncolor = '*' if monster.attr1.value == inherit_model.attr1.value or monster.attr1.name == NIL_ATT else ' ' + lines.append(TEAM_BUTTON_FORMAT.format(card.id, card.name.format(oncolor), + card.mult, colors_str, round(total_dmg * card.mult, 2))) + return "\n".join(lines) + + +class ButtonInfoStatSet: + def __init__(self, monster: "MonsterModel", main: Optional[float] = None): + self.main = main + if main is None: + self.sub = None + self.total = None + return + sub_attr_multiplier = _get_sub_attr_multiplier(monster) + self.sub = main * sub_attr_multiplier + self.total = self.main + self.sub + + +class ButtonInfoResult: + coop: ButtonInfoStatSet + solo: ButtonInfoStatSet + coop_slb: ButtonInfoStatSet + solo_slb: ButtonInfoStatSet + + coop_latent: ButtonInfoStatSet + solo_latent: ButtonInfoStatSet + coop_slb_latent: ButtonInfoStatSet + solo_slb_latent: ButtonInfoStatSet + + card_btn_str: str + card_btn_solo_str: str + card_btn_slb_str: str + card_btn_solo_slb_str: str + + team_btn_str: str + team_btn_solo_str: str + team_btn_slb_str: str + team_btn_solo_slb_str: str + + def get_card_btn_str(self, is_coop, max_110): + if is_coop and max_110: + return self.card_btn_str + elif is_coop and not max_110: + return self.card_btn_slb_str + elif not is_coop and max_110: + return self.card_btn_solo_str + elif not is_coop and not max_110: + return self.card_btn_solo_slb_str + + def get_team_btn_str(self, is_coop, max_110): + if is_coop and max_110: + return self.team_btn_str + elif is_coop and not max_110: + return self.team_btn_slb_str + elif not is_coop and max_110: + return self.team_btn_solo_str + elif not is_coop and not max_110: + return self.team_btn_solo_slb_str + + +button_info = ButtonInfo() diff --git a/padinfo/core/leader_skills.py b/padinfo/core/leader_skills.py new file mode 100644 index 00000000..a779e332 --- /dev/null +++ b/padinfo/core/leader_skills.py @@ -0,0 +1,104 @@ +import re + +from tsutils.tsubaki.custom_emoji import get_emoji + + +def _humanize_number(number, sigfigs=2): + n = float("{0:.{1}g}".format(number, sigfigs)) + if n >= 1e9: + return str(int(n // 1e9)) + "B" + elif n >= 1e6: + return str(int(n // 1e6)) + "M" + elif n >= 1e3: + return str(int(n // 1e3)) + "k" + else: + return str(int(n)) + + +def ls_multiplier_text(ls1, ls2=False): + if ls2 and not ls1: + ls1, ls2 = ls2, ls1 + + if ls1.__class__.__name__ == "LeaderSkillModel": + hp1, atk1, rcv1, resist1, combo1, fua1, mfua1, te1 = ls1.data + else: + hp1, atk1, rcv1, resist1, combo1, fua1, mfua1, te1 = 1, 1, 1, 0, 0, 0, 0, 0 + + if ls2.__class__.__name__ == "LeaderSkillModel": + hp2, atk2, rcv2, resist2, combo2, fua2, mfua2, te2 = ls2.data + elif ls2 is False: + hp2, atk2, rcv2, resist2, combo2, fua2, mfua2, te2 = hp1, atk1, rcv1, resist1, combo1, fua1, mfua1, te1 + elif ls2 is None: + hp2, atk2, rcv2, resist2, combo2, fua2, mfua2, te2 = 1, 1, 1, 0, 0, 0, 0, 0 + else: + raise ValueError(f"ls2 must be LeaderSkillModel or None, not {type(ls2)}") + + return _format_ls_text( + hp1 * hp2, + atk1 * atk2, + rcv1 * rcv2, + 1 - (1 - resist1) * (1 - resist2), + combo1 + combo2, + fua1 + fua2, + mfua1 + mfua2, + te1 + te2 + ) + + +def ls_single_multiplier_text(ls=None): + if ls: + hp, atk, rcv, resist, combo, fua, mfua, te = ls.data + else: + hp, atk, rcv, resist, combo, fua, mfua, te = 1, 1, 1, 0, 0, 0, 0, 0 + + return _format_ls_text(hp, atk, rcv, resist, combo, fua, mfua, te) + + +def _format_number(val): + return '{:.2f}'.format(val).strip('0').rstrip('.') + + +def _format_ls_text(hp, atk, rcv, resist=0, combo=0, fua=0, mfua=0, te=0): + resist_val = ' {}{}%'.format(get_emoji('shield_amount'), _format_number(100 * resist)) if resist else '' + + ehp = ' [{}x eHP]'.format(_format_number(hp / (1 - resist))) if resist else '' + + combos = '+{}c'.format(combo) if combo else '' + true_damage = '{}'.format(_humanize_number(fua, 2)) if fua else '' + any_fua = 'fua' if fua or mfua else '' + + joined = ' '.join((a for a in [combos, true_damage, any_fua] if a)) + extras = f"[{joined}]" if joined else '' + + return f"[{_format_number(hp)}/{_format_number(atk)}/{_format_number(rcv)}{resist_val}]{ehp} {extras}" + + +async def leaderskill_query(dbcog, raw_query, author_id): + # Remove unicode quotation marks + query = re.sub("[\u201c\u201d]", '"', raw_query) + + # deliberate order in case of multiple different separators. + for sep in ('"', '/', ',', ' '): + if sep in query: + + left_query, *right_query = [x.strip() for x in query.split(sep) if x.strip()] or ( + '', '') # or in case of ^ls [sep] which is empty list + # split on first separator, with if x.strip() block to prevent null values from showing up, mainly for quotes support + # right query is the rest of query but in list form because of how .strip() works. bring it back to string form with ' '.join + right_query = ' '.join(q for q in right_query) + if sep == ' ': + # Handle a very specific failure case, user typing something like "uuvo ragdra" + m = await dbcog.find_monster(query, author_id) + if m and left_query in (await dbcog.get_index(m.server_priority)).modifiers[m]: + left_query = query + right_query = None + break + + else: # no separators + left_query, right_query = query, None + left_m = await dbcog.find_monster(left_query, author_id) + if right_query: + right_m = await dbcog.find_monster(right_query, author_id) + else: + right_m = left_m + return left_m, left_query, right_m, right_query diff --git a/padinfo/core/padinfo_settings.py b/padinfo/core/padinfo_settings.py new file mode 100644 index 00000000..95aeee88 --- /dev/null +++ b/padinfo/core/padinfo_settings.py @@ -0,0 +1,54 @@ +from tsutils.cog_settings import CogSettings + + +class PadInfoSettings(CogSettings): + def make_default_settings(self): + config = { + 'animation_dir': '', + 'alt_id_optout': [], + 'voice_dir_path': '', + 'emoji_use': {}, + } + return config + + def emojiServers(self): + key = 'emoji_servers' + if key not in self.bot_settings: + self.bot_settings[key] = [] + return self.bot_settings[key] + + def setEmojiServers(self, emoji_servers): + es = self.emojiServers() + es.clear() + es.extend(emoji_servers) + self.save_settings() + + def setEvoID(self, user_id): + if self.checkEvoID(user_id): + return False + self.bot_settings['alt_id_optout'].remove(user_id) + self.save_settings() + return True + + def rmEvoID(self, user_id): + if not self.checkEvoID(user_id): + return False + self.bot_settings['alt_id_optout'].append(user_id) + self.save_settings() + return True + + def checkEvoID(self, user_id): + return user_id not in self.bot_settings['alt_id_optout'] + + def setVoiceDir(self, path): + self.bot_settings['voice_dir_path'] = path + self.save_settings() + + def voiceDir(self): + return self.bot_settings['voice_dir_path'] + + def log_emoji(self, emote): + self.bot_settings['emoji_use'][emote] = self.bot_settings['emoji_use'].get(emote, 0) + 1 + self.save_settings() + +settings = PadInfoSettings("padinfo") diff --git a/padinfo/core/transforminfo.py b/padinfo/core/transforminfo.py new file mode 100644 index 00000000..7db8e334 --- /dev/null +++ b/padinfo/core/transforminfo.py @@ -0,0 +1,32 @@ +async def perform_transforminfo_query(dbcog, raw_query, author_id): + db_context = dbcog.database + mgraph = dbcog.database.graph + found_monster = await dbcog.find_monster(raw_query, author_id) + + if not found_monster: + return None, None, None + + transformed_mon = None + base_mon = None + altversions = mgraph.get_alt_monsters(found_monster) + for mon in altversions: + if mgraph.monster_is_transform_base(mon): + transformed_mon = mgraph.get_next_transform(mon) + if transformed_mon: + base_mon = mon + break + + if not transformed_mon: + return found_monster, None, None + + reaction_ids = [base_mon.monster_id, transformed_mon.monster_id] + current = transformed_mon + while True: + next_transform = mgraph.get_next_transform(current) + if next_transform is not None and next_transform.monster_id not in reaction_ids: + reaction_ids.append(next_transform.monster_id) + current = next_transform + else: + break + + return base_mon, transformed_mon, reaction_ids diff --git a/padinfo/info.json b/padinfo/info.json new file mode 100644 index 00000000..7cd32991 --- /dev/null +++ b/padinfo/info.json @@ -0,0 +1,32 @@ +{ + "author": [ + "The Tsubaki Bot Team" + ], + "description": "Contains the bulk of PAD commands, allowing users to get data for monsters.", + "end_user_data_statement": "Previous ID queries are stored for easy reuse.", + "install_msg": "Use [p]helpid for help", + "short": "Serves monster data (LOAD THIS SECOND)", + "min_bot_version": "3.4.0", + "max_bot_version": "0.0.0", + "min_python_version": [ + 3, + 8, + 1 + ], + "hidden": false, + "disabled": false, + "required_cogs": { + "dbcog": "https://github.com/TsubakiBotPad/pad-cogs", + "speech": "https://github.com/TsubakiBotPad/core-cogs" + }, + "requirements": [ + "tsutils>=4.0.0", + "prettytable", + "python-levenshtein", + "discord-menu>=0.16.0", + "tabulate" + ], + "tags": [ + "PAD" + ] +} diff --git a/padinfo/menu/__init__.py b/padinfo/menu/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padinfo/menu/awakening_list.py b/padinfo/menu/awakening_list.py new file mode 100644 index 00000000..f8727d57 --- /dev/null +++ b/padinfo/menu/awakening_list.py @@ -0,0 +1,106 @@ +from typing import Optional + +from discord import Message +from discordmenu.embed.wrapper import EmbedWrapper +from discordmenu.embed.menu import EmbedMenu +from tsutils.menu.components.panes import MenuPanes, emoji_buttons +from tsutils.query_settings.enums import ChildMenuType + +from padinfo.view.awakening_list import AwakeningListSortTypes, AwakeningListView, AwakeningListViewState + + +class AwakeningListEmoji: + refresh = '\N{ANTICLOCKWISE DOWNWARDS AND UPWARDS OPEN CIRCLE ARROWS}' + home = emoji_buttons['home'] + prev_page = '\N{BLACK LEFT-POINTING DOUBLE TRIANGLE}' + next_page = '\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE}' + alphabetical = '\N{INPUT SYMBOL FOR LATIN LETTERS}' + numerical = '\N{INPUT SYMBOL FOR NUMBERS}' + + +class AwakeningListMenu: + MENU_TYPE = ChildMenuType.AwakeningList.name + + @staticmethod + def menu(initial_control=None): + if initial_control is None: + initial_control = AwakeningListMenu.awakening_list_control + embed = EmbedMenu(AwakeningListMenuPanes.transitions(), initial_control) + return embed + + @staticmethod + async def respond_with_left(message: Optional[Message], ims, **data): + cur_page = ims['current_page'] + if cur_page == 0: + ims['current_page'] = ims['total_pages'] - 1 + else: + ims['current_page'] = cur_page - 1 + return await AwakeningListMenu.respond_with_awakening_list(message, ims, **data) + + @staticmethod + async def respond_with_right(message: Optional[Message], ims, **data): + cur_page = ims['current_page'] + if cur_page == ims['total_pages'] - 1: + ims['current_page'] = 0 + else: + ims['current_page'] = cur_page + 1 + return await AwakeningListMenu.respond_with_awakening_list(message, ims, **data) + + @staticmethod + async def respond_with_alphabetical(message: Optional[Message], ims, **data): + ims['sort_type'] = AwakeningListSortTypes.alphabetical + return await AwakeningListMenu.respond_with_awakening_list(message, ims, **data) + + @staticmethod + async def respond_with_numerical(message: Optional[Message], ims, **data): + ims['sort_type'] = AwakeningListSortTypes.numerical + return await AwakeningListMenu.respond_with_awakening_list(message, ims, **data) + + @staticmethod + async def respond_with_awakening_list(message: Optional[Message], ims, **data): + dbcog = data['dbcog'] + user_config = data['user_config'] + view_state = await AwakeningListViewState.deserialize(dbcog, user_config, ims) + control = AwakeningListMenu.awakening_list_control(view_state) + return control + + @staticmethod + def awakening_list_control(state: AwakeningListViewState): + if state is None: + return None + reaction_list = AwakeningListMenuPanes.get_user_reaction_list(state.sort_type) + return EmbedWrapper( + AwakeningListView.embed(state), + reaction_list + ) + + +class AwakeningListMenuPanes(MenuPanes): + INITIAL_EMOJI = AwakeningListEmoji.home + + DATA = { + AwakeningListEmoji.home: (AwakeningListMenu.respond_with_awakening_list, AwakeningListView.VIEW_TYPE), + AwakeningListEmoji.prev_page: (AwakeningListMenu.respond_with_left, AwakeningListView.VIEW_TYPE), + AwakeningListEmoji.next_page: (AwakeningListMenu.respond_with_right, AwakeningListView.VIEW_TYPE), + AwakeningListEmoji.alphabetical: (AwakeningListMenu.respond_with_alphabetical, AwakeningListView.VIEW_TYPE), + AwakeningListEmoji.numerical: (AwakeningListMenu.respond_with_numerical, AwakeningListView.VIEW_TYPE), + } + + HIDDEN_EMOJIS = [ + AwakeningListEmoji.home, + AwakeningListEmoji.refresh, + ] + + OPTIONAL_EMOJIS = [ + AwakeningListEmoji.alphabetical, + AwakeningListEmoji.numerical, + ] + + @classmethod + def get_user_reaction_list(cls, cur_sort_type): + other_toggle_reaction = None + if cur_sort_type == AwakeningListSortTypes.alphabetical: + other_toggle_reaction = AwakeningListEmoji.numerical + elif cur_sort_type == AwakeningListSortTypes.numerical: + other_toggle_reaction = AwakeningListEmoji.alphabetical + return [_ for _ in cls.emoji_names() if _ not in cls.OPTIONAL_EMOJIS] + [other_toggle_reaction] diff --git a/padinfo/menu/button_info.py b/padinfo/menu/button_info.py new file mode 100644 index 00000000..9219505b --- /dev/null +++ b/padinfo/menu/button_info.py @@ -0,0 +1,145 @@ +from typing import Optional + +from discord import Message +from discordmenu.embed.wrapper import EmbedWrapper +from discordmenu.embed.menu import EmbedMenu +from tsutils.emoji import char_to_emoji +from tsutils.menu.components.panes import MenuPanes, emoji_buttons + +from padinfo.menu.components.evo_scroll_mixin import EvoScrollMenu +from padinfo.view.button_info import ButtonInfoOptions, ButtonInfoToggles, ButtonInfoView, ButtonInfoViewState + + +class ButtonInfoEmoji: + left = '\N{BLACK LEFT-POINTING TRIANGLE}' + right = '\N{BLACK RIGHT-POINTING TRIANGLE}' + home = emoji_buttons['home'] + coop = ('coop_2p', '\N{BUSTS IN SILHOUETTE}') + solo = ('solo_1p', '\N{BUST IN SILHOUETTE}') + desktop = '\N{DESKTOP COMPUTER}' + mobile = '\N{MOBILE PHONE}' + limit_break = ('lv110', char_to_emoji('1')) + super_limit_break = ('lv120', char_to_emoji('2')) + + +class ButtonInfoMenu(EvoScrollMenu): + MENU_TYPE = 'ButtonInfo' + VIEW_STATE_TYPE = ButtonInfoViewState + + @staticmethod + def get_panes_type(): + # TODO: change this to a property & classmethod once we update to Python 3.9 + return ButtonInfoMenuPanes + + @staticmethod + def menu(): + return EmbedMenu(ButtonInfoMenuPanes.transitions(), ButtonInfoMenu.button_info_control) + + @classmethod + async def _get_view_state(cls, ims: dict, **data) -> ButtonInfoViewState: + dbcog = data['dbcog'] + user_config = data['user_config'] + return await ButtonInfoViewState.deserialize(dbcog, user_config, ims) + + @staticmethod + async def respond_with_button_info(message: Optional[Message], ims, **data): + dbcog = data['dbcog'] + user_config = data['user_config'] + view_state = await ButtonInfoViewState.deserialize(dbcog, user_config, ims) + control = ButtonInfoMenu.button_info_control(view_state) + return control + + @classmethod + async def respond_with_coop(cls, message: Optional[Message], ims, **data): + view_state = await cls._get_view_state(ims, **data) + view_state.set_player_count(ButtonInfoOptions.coop) + return ButtonInfoMenu.button_info_control(view_state) + + @classmethod + async def respond_with_solo(cls, message: Optional[Message], ims, **data): + view_state = await cls._get_view_state(ims, **data) + view_state.set_player_count(ButtonInfoOptions.solo) + return ButtonInfoMenu.button_info_control(view_state) + + @classmethod + async def respond_with_desktop(cls, message: Optional[Message], ims, **data): + view_state = await cls._get_view_state(ims, **data) + view_state.set_device(ButtonInfoOptions.desktop) + return ButtonInfoMenu.button_info_control(view_state) + + @classmethod + async def respond_with_mobile(cls, message: Optional[Message], ims, **data): + view_state = await cls._get_view_state(ims, **data) + view_state.set_device(ButtonInfoOptions.mobile) + return ButtonInfoMenu.button_info_control(view_state) + + @classmethod + async def respond_with_limit_break(cls, message: Optional[Message], ims, **data): + view_state = await cls._get_view_state(ims, **data) + view_state.set_max_level(ButtonInfoOptions.limit_break) + return ButtonInfoMenu.button_info_control(view_state) + + @classmethod + async def respond_with_super_limit_break(cls, message: Optional[Message], ims, **data): + view_state = await cls._get_view_state(ims, **data) + view_state.set_max_level(ButtonInfoOptions.super_limit_break) + return ButtonInfoMenu.button_info_control(view_state) + + @staticmethod + def button_info_control(state: ButtonInfoViewState): + if state is None: + return None + reaction_list = ButtonInfoMenuPanes.get_user_reaction_list(state.display_options) + return EmbedWrapper( + ButtonInfoView.embed(state), + reaction_list + ) + + +class ButtonInfoMenuPanes(MenuPanes): + INITIAL_EMOJI = ButtonInfoEmoji.home + + DATA = { + ButtonInfoEmoji.left: (ButtonInfoMenu.respond_with_left, None), + ButtonInfoEmoji.right: (ButtonInfoMenu.respond_with_right, None), + ButtonInfoEmoji.home: (ButtonInfoMenu.respond_with_button_info, ButtonInfoView.VIEW_TYPE), + ButtonInfoEmoji.coop: (ButtonInfoMenu.respond_with_coop, ButtonInfoView.VIEW_TYPE), + ButtonInfoEmoji.solo: (ButtonInfoMenu.respond_with_solo, ButtonInfoView.VIEW_TYPE), + ButtonInfoEmoji.desktop: (ButtonInfoMenu.respond_with_desktop, ButtonInfoView.VIEW_TYPE), + ButtonInfoEmoji.mobile: (ButtonInfoMenu.respond_with_mobile, ButtonInfoView.VIEW_TYPE), + ButtonInfoEmoji.limit_break: (ButtonInfoMenu.respond_with_limit_break, ButtonInfoView.VIEW_TYPE), + ButtonInfoEmoji.super_limit_break: (ButtonInfoMenu.respond_with_super_limit_break, ButtonInfoView.VIEW_TYPE), + } + + HIDDEN_EMOJIS = [ + ButtonInfoEmoji.home, + ] + + OPTIONAL_EMOJIS = [ + ButtonInfoEmoji.coop, + ButtonInfoEmoji.solo, + ButtonInfoEmoji.desktop, + ButtonInfoEmoji.mobile, + ButtonInfoEmoji.limit_break, + ButtonInfoEmoji.super_limit_break, + ] + + @classmethod + def get_user_reaction_list(cls, current_options: ButtonInfoToggles): + other_toggle_reactions = [] + if current_options.players == ButtonInfoOptions.coop: + other_toggle_reactions.append(ButtonInfoEmoji.solo) + elif current_options.players == ButtonInfoOptions.solo: + other_toggle_reactions.append(ButtonInfoEmoji.coop) + + if current_options.device == ButtonInfoOptions.desktop: + other_toggle_reactions.append(ButtonInfoEmoji.mobile) + elif current_options.device == ButtonInfoOptions.mobile: + other_toggle_reactions.append(ButtonInfoEmoji.desktop) + + if current_options.max_level == ButtonInfoOptions.limit_break: + other_toggle_reactions.append(ButtonInfoEmoji.super_limit_break) + elif current_options.max_level == ButtonInfoOptions.super_limit_break: + other_toggle_reactions.append(ButtonInfoEmoji.limit_break) + + return [_ for _ in cls.emoji_names() if _ not in cls.OPTIONAL_EMOJIS] + other_toggle_reactions diff --git a/padinfo/menu/closable_embed.py b/padinfo/menu/closable_embed.py new file mode 100644 index 00000000..1a3cb2f0 --- /dev/null +++ b/padinfo/menu/closable_embed.py @@ -0,0 +1,19 @@ +from tsutils.menu.closable_embed_base import ClosableEmbedMenuBase + +from padinfo.view.awakening_help import AwakeningHelpView +from padinfo.view.dungeon_list.jp_dungeon_name import JpDungeonNameView +from padinfo.view.dungeon_list.jpytdglead import JpYtDgLeadView +from padinfo.view.dungeon_list.skyo_links import SkyoLinksView +from padinfo.view.experience_curve import ExperienceCurveView +from padinfo.view.id_traceback import IdTracebackView + + +class ClosableEmbedMenu(ClosableEmbedMenuBase): + view_types = { + AwakeningHelpView.VIEW_TYPE: AwakeningHelpView, + IdTracebackView.VIEW_TYPE: IdTracebackView, + ExperienceCurveView.VIEW_TYPE: ExperienceCurveView, + SkyoLinksView.VIEW_TYPE: SkyoLinksView, + JpDungeonNameView.VIEW_TYPE: JpDungeonNameView, + JpYtDgLeadView.VIEW_TYPE: JpYtDgLeadView, + } diff --git a/padinfo/menu/components/evo_scroll_mixin.py b/padinfo/menu/components/evo_scroll_mixin.py new file mode 100644 index 00000000..8e6f06c0 --- /dev/null +++ b/padinfo/menu/components/evo_scroll_mixin.py @@ -0,0 +1,50 @@ +from abc import abstractmethod +from typing import Optional + +from discord import Message +from tsutils.menu.components.panes import MenuPanes + +from padinfo.view.components.evo_scroll_mixin import EvoScrollViewState + + +class EvoScrollMenu: + VIEW_STATE_TYPE: EvoScrollViewState + + @staticmethod + @abstractmethod + def get_panes_type() -> MenuPanes: + ... + + @classmethod + async def respond_with_left(cls, message: Optional[Message], ims, **data): + dbcog = data['dbcog'] + user_config = data['user_config'] + # Figure out the new monster before doing all of the queries necessary for + # The specific pane type. For now just deserialize as the base Id ViewState. + view_state = await cls.VIEW_STATE_TYPE.deserialize(dbcog, user_config, ims) + view_state.decrement_monster(dbcog, ims) + + response_func = cls._get_response_func(ims) + return await response_func(message, ims, **data) + + @classmethod + async def respond_with_right(cls, message: Optional[Message], ims, **data): + dbcog = data['dbcog'] + user_config = data['user_config'] + view_state = await cls.VIEW_STATE_TYPE.deserialize(dbcog, user_config, ims) + view_state.increment_monster(dbcog, ims) + + response_func = cls._get_response_func(ims) + return await response_func(message, ims, **data) + + @classmethod + def _get_response_func(cls, ims): + panes = cls.get_panes_type() + + pane_type = ims.get('pane_type') + if pane_type is not None: + pane_type_to_func_map = panes.pane_types() + return pane_type_to_func_map[pane_type] + + home = panes.INITIAL_EMOJI + return panes.transitions()[home] diff --git a/padinfo/menu/droploc.py b/padinfo/menu/droploc.py new file mode 100644 index 00000000..33e32973 --- /dev/null +++ b/padinfo/menu/droploc.py @@ -0,0 +1,71 @@ +from typing import Optional + +from discord import Message +from discordmenu.embed.menu import EmbedMenu +from discordmenu.embed.wrapper import EmbedWrapper +from tsutils.menu.components.panes import MenuPanes + +from padinfo.menu.components.evo_scroll_mixin import EvoScrollMenu +from padinfo.view.droploc import DroplocViewState, DroplocView + + +class DroplocEmoji: + left = '\N{BLACK LEFT-POINTING TRIANGLE}' + right = '\N{BLACK RIGHT-POINTING TRIANGLE}' + home = '\N{HOUSE BUILDING}' + expand = 'plus' + contract = 'minus' + + +class DroplocMenu(EvoScrollMenu): + + @staticmethod + def get_panes_type(): + return DroplocMenuPanes + + @staticmethod + def menu(initial_control=None): + if initial_control is None: + initial_control = DroplocMenu.pane_control + embed = EmbedMenu(DroplocMenuPanes.transitions(), initial_control) + return embed + + @staticmethod + async def respond_with_expand(_message: Optional[Message], ims, **data): + dbcog = data['dbcog'] + user_config = data['user_config'] + DroplocViewState.expand(ims) + view_state = await DroplocViewState.deserialize(dbcog, user_config, ims) + control = DroplocMenu.pane_control(view_state) + return control + + @staticmethod + async def respond_with_contract(_message: Optional[Message], ims, **data): + dbcog = data['dbcog'] + user_config = data['user_config'] + DroplocViewState.contract(ims) + view_state = await DroplocViewState.deserialize(dbcog, user_config, ims) + control = DroplocMenu.pane_control(view_state) + return control + + @staticmethod + def pane_control(state: DroplocViewState): + if state is None: + return None + return EmbedWrapper( + DroplocView.embed(state), + state.reaction_list or DroplocMenuPanes.emoji_names() + ) + + +class DroplocMenuPanes(MenuPanes): + DATA = { + DroplocEmoji.left: (DroplocMenu.respond_with_left, None), + DroplocEmoji.right: (DroplocMenu.respond_with_right, None), + DroplocEmoji.expand: (DroplocMenu.respond_with_expand, None), + DroplocEmoji.contract: (DroplocMenu.respond_with_contract, None), + } + + HIDDEN_EMOJIS = [ + DroplocEmoji.home, + ] diff --git a/padinfo/menu/id.py b/padinfo/menu/id.py new file mode 100644 index 00000000..d1bf46de --- /dev/null +++ b/padinfo/menu/id.py @@ -0,0 +1,199 @@ +from typing import Optional + +from discord import Message +from discordmenu.embed.emoji import DELETE_MESSAGE_EMOJI +from discordmenu.embed.menu import EmbedMenu +from discordmenu.embed.transitions import EmbedMenuDefaultTransitions, EmbedTransition +from discordmenu.embed.wrapper import EmbedWrapper +from tsutils.emoji import char_to_emoji +from tsutils.menu.components.panes import MenuPanes +from tsutils.menu.simple_text import SimpleTextMenu +from tsutils.query_settings.enums import ChildMenuType + +from padinfo.menu.components.evo_scroll_mixin import EvoScrollMenu +from padinfo.view.components.view_state_base_id import ViewStateBaseId +from padinfo.view.evos import EvosView, EvosViewState +from padinfo.view.id import IdView, IdViewState +from padinfo.view.materials import MaterialsView, MaterialsViewState +from padinfo.view.otherinfo import OtherInfoView, OtherInfoViewState +from padinfo.view.pantheon import PantheonView, PantheonViewState +from padinfo.view.pic import PicView, PicViewState + + +class IdMenu(EvoScrollMenu): + MENU_TYPE = ChildMenuType.IdMenu.name + VIEW_STATE_TYPE = ViewStateBaseId + + @staticmethod + def get_panes_type(): + # TODO: change this to a property & classmethod once we update to Python 3.9 + return IdMenuPanes + + @staticmethod + def menu(initial_control=None): + if initial_control is None: + initial_control = IdMenu.id_control + + embed = EmbedMenu(IdMenuPanes.transitions(), initial_control, + EmbedMenuDefaultTransitions( + delete_message=EmbedTransition(DELETE_MESSAGE_EMOJI, IdMenu.respond_with_delete))) + return embed + + @staticmethod + async def respond_with_refresh(message: Optional[Message], ims, **data): + # This is used by disambig screen & other multi-message embeds, where we need to deserialize & then + # re-serialize the ims, with the same information in place + pane_type = ims.get('pane_type') or IdView.VIEW_TYPE + pane_type_to_func_map = IdMenuPanes.pane_types() + response_func = pane_type_to_func_map[pane_type] + return await response_func(message, ims, **data) + + @staticmethod + async def respond_with_delete(message: Optional[Message], ims, **data): + if not ims.get('is_child'): + return await message.delete() + if ims.get('idle_message'): + ims['menu_type'] = SimpleTextMenu.MENU_TYPE + ims['message'] = ims['idle_message'] + return await SimpleTextMenu.respond_with_message(message, ims, **data) + return await message.edit(embed=None) + + @staticmethod + async def respond_with_current_id(message: Optional[Message], ims, **data): + dbcog = data['dbcog'] + user_config = data['user_config'] + + view_state = await IdViewState.deserialize(dbcog, user_config, ims) + control = IdMenu.id_control(view_state) + return control + + @staticmethod + async def respond_with_evos(message: Optional[Message], ims, **data): + dbcog = data['dbcog'] + user_config = data['user_config'] + + view_state = await EvosViewState.deserialize(dbcog, user_config, ims) + control = IdMenu.evos_control(view_state) + return control + + @staticmethod + async def respond_with_mats(message: Optional[Message], ims, **data): + dbcog = data['dbcog'] + user_config = data['user_config'] + + view_state = await MaterialsViewState.deserialize(dbcog, user_config, ims) + control = IdMenu.mats_control(view_state) + return control + + @staticmethod + async def respond_with_picture(message: Optional[Message], ims, **data): + dbcog = data['dbcog'] + user_config = data['user_config'] + + view_state = await PicViewState.deserialize(dbcog, user_config, ims) + control = IdMenu.pic_control(view_state) + return control + + @staticmethod + async def respond_with_pantheon(message: Optional[Message], ims, **data): + dbcog = data['dbcog'] + user_config = data['user_config'] + + view_state = await PantheonViewState.deserialize(dbcog, user_config, ims) + control = IdMenu.pantheon_control(view_state) + return control + + @staticmethod + async def respond_with_otherinfo(message: Optional[Message], ims, **data): + dbcog = data['dbcog'] + user_config = data['user_config'] + + view_state = await OtherInfoViewState.deserialize(dbcog, user_config, ims) + control = IdMenu.otherinfo_control(view_state) + return control + + @staticmethod + def id_control(state: IdViewState): + if state is None: + return None + return EmbedWrapper( + IdView.embed(state), + state.reaction_list or IdMenuPanes.emoji_names() + ) + + @staticmethod + def evos_control(state: Optional[EvosViewState]): + if state is None: + return None + return EmbedWrapper( + EvosView.embed(state), + state.reaction_list or IdMenuPanes.emoji_names() + ) + + @staticmethod + def mats_control(state: Optional[MaterialsViewState]): + if state is None: + return None + return EmbedWrapper( + MaterialsView.embed(state), + state.reaction_list or IdMenuPanes.emoji_names() + ) + + @staticmethod + def pic_control(state: PicViewState): + if state is None: + return None + return EmbedWrapper( + PicView.embed(state), + state.reaction_list or IdMenuPanes.emoji_names() + ) + + @staticmethod + def pantheon_control(state: Optional[PantheonViewState]): + if state is None: + return None + return EmbedWrapper( + PantheonView.embed(state), + state.reaction_list or IdMenuPanes.emoji_names() + ) + + @staticmethod + def otherinfo_control(state: OtherInfoViewState): + if state is None: + return None + return EmbedWrapper( + OtherInfoView.embed(state), + state.reaction_list or IdMenuPanes.emoji_names() + ) + + +class IdMenuEmoji: + left = '\N{BLACK LEFT-POINTING TRIANGLE}' + right = '\N{BLACK RIGHT-POINTING TRIANGLE}' + home = '\N{HOUSE BUILDING}' + evos = char_to_emoji('E') + mats = '\N{MEAT ON BONE}' + pic = '\N{FRAME WITH PICTURE}' + pantheon = '\N{CLASSICAL BUILDING}' + otherinfo = '\N{SCROLL}' + refresh = "\N{ANTICLOCKWISE DOWNWARDS AND UPWARDS OPEN CIRCLE ARROWS}" + delete = '\N{CROSS MARK}' + + +class IdMenuPanes(MenuPanes): + DATA = { + IdMenuEmoji.left: (IdMenu.respond_with_left, None), + IdMenuEmoji.right: (IdMenu.respond_with_right, None), + IdMenuEmoji.home: (IdMenu.respond_with_current_id, IdView.VIEW_TYPE), + IdMenuEmoji.evos: (IdMenu.respond_with_evos, EvosView.VIEW_TYPE), + IdMenuEmoji.mats: (IdMenu.respond_with_mats, MaterialsView.VIEW_TYPE), + IdMenuEmoji.pic: (IdMenu.respond_with_picture, PicView.VIEW_TYPE), + IdMenuEmoji.pantheon: (IdMenu.respond_with_pantheon, PantheonView.VIEW_TYPE), + IdMenuEmoji.otherinfo: (IdMenu.respond_with_otherinfo, OtherInfoView.VIEW_TYPE), + IdMenuEmoji.refresh: (IdMenu.respond_with_refresh, None), + IdMenuEmoji.delete: (IdMenu.respond_with_delete, None), + } + HIDDEN_EMOJIS = [ + IdMenuEmoji.refresh, + IdMenuEmoji.delete, + ] diff --git a/padinfo/menu/leader_skill.py b/padinfo/menu/leader_skill.py new file mode 100644 index 00000000..b14488de --- /dev/null +++ b/padinfo/menu/leader_skill.py @@ -0,0 +1,81 @@ +from typing import Optional + +from discord import Message +from discordmenu.embed.menu import EmbedMenu +from discordmenu.embed.wrapper import EmbedWrapper +from tsutils.emoji import char_to_emoji +from tsutils.menu.components.panes import MenuPanes, emoji_buttons + +from padinfo.view.id import IdView, IdViewState +from padinfo.view.leader_skill import LeaderSkillView, LeaderSkillViewState + + +class LeaderSkillMenu: + MENU_TYPE = 'LeaderSkill' + + @staticmethod + def menu(): + return EmbedMenu(LeaderSkillMenuPanes.transitions(), LeaderSkillMenu.ls_control) + + @staticmethod + async def respond_with_right(message: Optional[Message], ims, **data): + dbcog = data['dbcog'] + user_config = data['user_config'] + + # Extract the query from the ls state + ims['query'] = ims['r_query'] + ims['query_settings'] = ims['r_query_settings'] + ims['resolved_monster_id'] = None + id_view_state = await IdViewState.deserialize(dbcog, user_config, ims) + id_control = LeaderSkillMenu.id_control(id_view_state) + return id_control + + @staticmethod + async def respond_with_left(message: Optional[Message], ims, **data): + dbcog = data['dbcog'] + user_config = data['user_config'] + + # Extract the query from the ls state + ims['query'] = ims['l_query'] + ims['query_settings'] = ims['l_query_settings'] + ims['resolved_monster_id'] = None + id_view_state = await IdViewState.deserialize(dbcog, user_config, ims) + id_control = LeaderSkillMenu.id_control(id_view_state) + return id_control + + @staticmethod + async def respond_with_house(message: Optional[Message], ims, **data): + dbcog = data['dbcog'] + user_config = data['user_config'] + ls_view_state = await LeaderSkillViewState.deserialize(dbcog, user_config, ims) + ls_control = LeaderSkillMenu.ls_control(ls_view_state) + return ls_control + + @staticmethod + def ls_control(state: LeaderSkillViewState): + return EmbedWrapper( + LeaderSkillView.embed(state), + LeaderSkillMenuPanes.emoji_names() + ) + + @staticmethod + def id_control(state: IdViewState): + return EmbedWrapper( + IdView.embed(state), + LeaderSkillMenuPanes.emoji_names() + ) + + +class LeaderSkillEmoji: + home = emoji_buttons['home'] + left = char_to_emoji('l') + right = char_to_emoji('r') + + +class LeaderSkillMenuPanes(MenuPanes): + INITIAL_EMOJI = LeaderSkillEmoji.home + DATA = { + LeaderSkillEmoji.home: (LeaderSkillMenu.respond_with_house, None), + LeaderSkillEmoji.left: (LeaderSkillMenu.respond_with_left, IdView.VIEW_TYPE), + LeaderSkillEmoji.right: (LeaderSkillMenu.respond_with_right, IdView.VIEW_TYPE), + } diff --git a/padinfo/menu/leader_skill_single.py b/padinfo/menu/leader_skill_single.py new file mode 100644 index 00000000..7997a114 --- /dev/null +++ b/padinfo/menu/leader_skill_single.py @@ -0,0 +1,57 @@ +from typing import Optional + +from discord import Message +from discordmenu.embed.menu import EmbedMenu +from discordmenu.embed.wrapper import EmbedWrapper +from tsutils.menu.components.panes import MenuPanes, emoji_buttons + +from padinfo.view.id import IdView, IdViewState +from padinfo.view.leader_skill_single import LeaderSkillSingleView, LeaderSkillSingleViewState + + +class LeaderSkillSingleMenu: + MENU_TYPE = 'LeaderSkillSingle' + + @staticmethod + def menu(): + return EmbedMenu(LeaderSkillSingleMenuPanes.transitions(), + LeaderSkillSingleMenu.ls_control) + + @staticmethod + async def respond_with_ls(message: Optional[Message], ims, *, dbcog, user_config, **data): + ls_view_state = await LeaderSkillSingleViewState.deserialize(dbcog, user_config, ims) + ls_control = LeaderSkillSingleMenu.ls_control(ls_view_state) + return ls_control + + @staticmethod + async def respond_with_id(message: Optional[Message], ims, *, dbcog, user_config, **data): + id_view_state = await IdViewState.deserialize(dbcog, user_config, ims) + id_control = LeaderSkillSingleMenu.id_control(id_view_state) + return id_control + + @staticmethod + def ls_control(state: LeaderSkillSingleViewState): + return EmbedWrapper( + LeaderSkillSingleView.embed(state), + LeaderSkillSingleMenuPanes.emoji_names() + ) + + @staticmethod + def id_control(state: IdViewState): + return EmbedWrapper( + IdView.embed(state), + LeaderSkillSingleMenuPanes.emoji_names() + ) + + +class LeaderSkillSingleEmoji: + home = emoji_buttons["home"] + id = '\N{SQUARED ID}' + + +class LeaderSkillSingleMenuPanes(MenuPanes): + INITIAL_EMOJI = LeaderSkillSingleEmoji.home + DATA = { + LeaderSkillSingleEmoji.home: (LeaderSkillSingleMenu.respond_with_ls, None), + LeaderSkillSingleEmoji.id: (LeaderSkillSingleMenu.respond_with_id, IdView.VIEW_TYPE), + } diff --git a/padinfo/menu/menu_map.py b/padinfo/menu/menu_map.py new file mode 100644 index 00000000..b97953b1 --- /dev/null +++ b/padinfo/menu/menu_map.py @@ -0,0 +1,36 @@ +from tsutils.menu.closable_embed_base import ClosableEmbedMenuPanes +from tsutils.menu.simple_text import SimpleTextMenu, SimpleTextMenuPanes + +from padinfo.menu.awakening_list import AwakeningListMenu, AwakeningListMenuPanes +from padinfo.menu.button_info import ButtonInfoMenu, ButtonInfoMenuPanes +from padinfo.menu.closable_embed import ClosableEmbedMenu +from padinfo.menu.id import IdMenu, IdMenuPanes +from padinfo.menu.leader_skill import LeaderSkillMenu, LeaderSkillMenuPanes +from padinfo.menu.leader_skill_single import LeaderSkillSingleMenu, LeaderSkillSingleMenuPanes +from padinfo.menu.monster_list import MonsterListMenu, MonsterListMenuPanes +from padinfo.menu.na_diff import NaDiffMenu, NaDiffMenuPanes +from padinfo.menu.scroll import ScrollMenuPanes +from padinfo.menu.series_scroll import SeriesScrollMenu, SeriesScrollMenuPanes +from padinfo.menu.transforminfo import TransformInfoMenu, TransformInfoMenuPanes +from padinfo.view.monster_list.all_mats import AllMatsViewState +from padinfo.view.monster_list.id_search import IdSearchViewState +from padinfo.view.monster_list.scroll import ScrollViewState +from padinfo.view.monster_list.static_monster_list import StaticMonsterListViewState + +padinfo_menu_map = { + AwakeningListMenu.MENU_TYPE: (AwakeningListMenu, AwakeningListMenuPanes), + ButtonInfoMenu.MENU_TYPE: (ButtonInfoMenu, ButtonInfoMenuPanes), + ClosableEmbedMenu.MENU_TYPE: (ClosableEmbedMenu, ClosableEmbedMenuPanes), + IdMenu.MENU_TYPE: (IdMenu, IdMenuPanes), + LeaderSkillMenu.MENU_TYPE: (LeaderSkillMenu, LeaderSkillMenuPanes), + LeaderSkillSingleMenu.MENU_TYPE: (LeaderSkillSingleMenu, LeaderSkillSingleMenuPanes), + MonsterListMenu.MENU_TYPE: (MonsterListMenu, MonsterListMenuPanes), + AllMatsViewState.VIEW_STATE_TYPE: (MonsterListMenu, MonsterListMenuPanes), + IdSearchViewState.VIEW_STATE_TYPE: (MonsterListMenu, MonsterListMenuPanes), + ScrollViewState.VIEW_STATE_TYPE: (MonsterListMenu, ScrollMenuPanes), + StaticMonsterListViewState.VIEW_STATE_TYPE: (MonsterListMenu, MonsterListMenuPanes), + SeriesScrollMenu.MENU_TYPE: (SeriesScrollMenu, SeriesScrollMenuPanes), + SimpleTextMenu.MENU_TYPE: (SimpleTextMenu, SimpleTextMenuPanes), + TransformInfoMenu.MENU_TYPE: (TransformInfoMenu, TransformInfoMenuPanes), + NaDiffMenu.MENU_TYPE: (NaDiffMenu, NaDiffMenuPanes), +} diff --git a/padinfo/menu/monster_list.py b/padinfo/menu/monster_list.py new file mode 100644 index 00000000..c4636dea --- /dev/null +++ b/padinfo/menu/monster_list.py @@ -0,0 +1,270 @@ +from typing import List, Optional, Type + +from discord import Message +from discordmenu.embed.menu import EmbedMenu +from discordmenu.embed.wrapper import EmbedWrapper +from tsutils.emoji import char_to_emoji +from tsutils.menu.components.panes import MenuPanes, emoji_buttons + +from padinfo.menu.id import IdMenu, IdMenuEmoji +from padinfo.menu.na_diff import NaDiffEmoji, NaDiffMenu +from padinfo.view.id import IdView +from padinfo.view.monster_list.all_mats import AllMatsViewState +from padinfo.view.monster_list.id_search import IdSearchViewState +from padinfo.view.monster_list.monster_list import MonsterListView, MonsterListViewState +from padinfo.view.monster_list.scroll import ScrollView, ScrollViewState +from padinfo.view.monster_list.static_monster_list import StaticMonsterListViewState + + +class MonsterListEmoji: + delete = '\N{CROSS MARK}' + home = emoji_buttons['home'] + prev_page = '\N{BLACK LEFT-POINTING DOUBLE TRIANGLE}' + prev_mon = '\N{BLACK LEFT-POINTING TRIANGLE}' + next_mon = '\N{BLACK RIGHT-POINTING TRIANGLE}' + next_page = '\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE}' + zero = char_to_emoji('0') + one = char_to_emoji('1') + two = char_to_emoji('2') + three = char_to_emoji('3') + four = char_to_emoji('4') + five = char_to_emoji('5') + six = char_to_emoji('6') + seven = char_to_emoji('7') + eight = char_to_emoji('8') + nine = char_to_emoji('9') + ten = char_to_emoji('10') + refresh = '\N{ANTICLOCKWISE DOWNWARDS AND UPWARDS OPEN CIRCLE ARROWS}' + reset = emoji_buttons['reset'] + + +class MonsterListMenu: + MENU_TYPE = 'MonsterListMenu' + + view_state_types = { + AllMatsViewState.VIEW_STATE_TYPE: AllMatsViewState, + IdSearchViewState.VIEW_STATE_TYPE: IdSearchViewState, + StaticMonsterListViewState.VIEW_STATE_TYPE: StaticMonsterListViewState, + ScrollViewState.VIEW_STATE_TYPE: ScrollViewState, + } + + view_types = { + ScrollViewState.VIEW_STATE_TYPE: ScrollView + } + + child_menu_type_to_emoji_response_map = { + IdMenu.MENU_TYPE: IdMenuEmoji.refresh, + NaDiffMenu.MENU_TYPE: NaDiffEmoji.home + } + + @staticmethod + def menu(initial_control=None): + if initial_control is None: + initial_control = MonsterListMenu.monster_list_control + embed = EmbedMenu(MonsterListMenuPanes.transitions(), initial_control) + return embed + + @classmethod + async def _get_view_state(cls, ims: dict, **data) -> MonsterListViewState: + dbcog = data['dbcog'] + user_config = data['user_config'] + view_state_class = cls.view_state_types[ims['menu_type']] + return await view_state_class.deserialize(dbcog, user_config, ims) + + @classmethod + def _get_view(cls, state: MonsterListViewState) -> Type[MonsterListView]: + return cls.view_types.get(state.VIEW_STATE_TYPE) or MonsterListView + + @staticmethod + async def respond_with_refresh(message: Optional[Message], ims, **data): + # this is only called once on message load + if data.get('child_message_id'): + ims['child_message_id'] = data['child_message_id'] + return await MonsterListMenu.respond_with_monster_list(message, ims, **data) + + @staticmethod + async def respond_with_reset(message: Optional[Message], ims, **data): + # replace with the overview list after the child menu changes + return await MonsterListMenu.respond_with_monster_list(message, ims, **data) + + @classmethod + async def respond_with_left(cls, message: Optional[Message], ims, **data): + view_state = await cls._get_view_state(ims, **data) + view_state.decrement_page() + return MonsterListMenu.monster_list_control(view_state) + + @classmethod + async def respond_with_right(cls, message: Optional[Message], ims, **data): + view_state = await cls._get_view_state(ims, **data) + view_state.increment_page() + return MonsterListMenu.monster_list_control(view_state) + + @classmethod + async def respond_with_monster_list(cls, message: Optional[Message], ims, **data): + view_state = await cls._get_view_state(ims, **data) + return MonsterListMenu.monster_list_control(view_state) + + @classmethod + async def respond_with_previous_monster(cls, message: Optional[Message], ims, **data): + view_state = await cls._get_view_state(ims, **data) + await view_state.decrement_index(data['dbcog']) + return MonsterListMenu.monster_list_control(view_state) + + @classmethod + async def respond_with_next_monster(cls, message: Optional[Message], ims, **data): + view_state = await cls._get_view_state(ims, **data) + await view_state.increment_index(data['dbcog']) + return MonsterListMenu.monster_list_control(view_state) + + @staticmethod + async def respond_with_delete(message: Message, ims, **data): + return await message.delete() + + @staticmethod + async def respond_with_n(message: Optional[Message], ims, n, **data): + ims['current_index'] = n + return await MonsterListMenu.respond_with_monster_list(message, ims, **data) + + @staticmethod + async def respond_with_0(message: Optional[Message], ims, **data): + return await MonsterListMenu.respond_with_n(message, ims, 0, **data) + + @staticmethod + async def respond_with_1(message: Optional[Message], ims, **data): + return await MonsterListMenu.respond_with_n(message, ims, 1, **data) + + @staticmethod + async def respond_with_2(message: Optional[Message], ims, **data): + return await MonsterListMenu.respond_with_n(message, ims, 2, **data) + + @staticmethod + async def respond_with_3(message: Optional[Message], ims, **data): + return await MonsterListMenu.respond_with_n(message, ims, 3, **data) + + @staticmethod + async def respond_with_4(message: Optional[Message], ims, **data): + return await MonsterListMenu.respond_with_n(message, ims, 4, **data) + + @staticmethod + async def respond_with_5(message: Optional[Message], ims, **data): + return await MonsterListMenu.respond_with_n(message, ims, 5, **data) + + @staticmethod + async def respond_with_6(message: Optional[Message], ims, **data): + return await MonsterListMenu.respond_with_n(message, ims, 6, **data) + + @staticmethod + async def respond_with_7(message: Optional[Message], ims, **data): + return await MonsterListMenu.respond_with_n(message, ims, 7, **data) + + @staticmethod + async def respond_with_8(message: Optional[Message], ims, **data): + return await MonsterListMenu.respond_with_n(message, ims, 8, **data) + + @staticmethod + async def respond_with_9(message: Optional[Message], ims, **data): + return await MonsterListMenu.respond_with_n(message, ims, 9, **data) + + @staticmethod + async def respond_with_10(message: Optional[Message], ims, **data): + return await MonsterListMenu.respond_with_n(message, ims, 10, **data) + + @classmethod + def monster_list_control(cls, state: MonsterListViewState): + if state is None: + return None + reaction_list = state.reaction_list + view_type = cls._get_view(state) + return EmbedWrapper( + view_type.embed(state), + reaction_list + ) + + @classmethod + async def click_child_number(cls, ims, emoji_clicked, **data): + if not MonsterListMenuPanes.respond_to_emoji_with_child(emoji_clicked): + return None, {} + n = MonsterListMenuPanes.emoji_names().index(emoji_clicked) + view_state = await cls._get_view_state(ims, **data) + view_state.set_index(MonsterListMenuPanes.get_monster_index(n)) + extra_ims = view_state.get_serialized_child_extra_ims() + emoji_response = cls.child_menu_type_to_emoji_response_map[view_state.child_menu_type] + return emoji_response, extra_ims + + @classmethod + async def auto_scroll_child_left(cls, ims, _emoji_clicked, **data): + view_state = await cls._get_view_state(ims, **data) + return await MonsterListMenu._scroll_child(view_state, view_state.decrement_index, data['dbcog']) + + @classmethod + async def auto_scroll_child_right(cls, ims, _emoji_clicked, **data): + view_state = await cls._get_view_state(ims, **data) + return await MonsterListMenu._scroll_child(view_state, view_state.increment_index, data['dbcog']) + + @classmethod + async def _scroll_child(cls, view_state: MonsterListViewState, update_function, dbcog): + # when the parent ims updated, it was a deepcopy of the ims / view_state we have now, + # so we have to redo the update function that we did before + await update_function(dbcog) + extra_ims = view_state.get_serialized_child_extra_ims() + emoji_response = cls.child_menu_type_to_emoji_response_map[view_state.child_menu_type] + return emoji_response, extra_ims + + +class MonsterListMenuPanes(MenuPanes): + INITIAL_EMOJI = MonsterListEmoji.home + NON_MONSTER_EMOJI_COUNT = 5 + DATA = { + # tuple parts: parent_response, pane_type, respond_with_child + MonsterListEmoji.delete: (MonsterListMenu.respond_with_delete, None, None), + MonsterListEmoji.home: (MonsterListMenu.respond_with_monster_list, MonsterListEmoji.home, None), + MonsterListEmoji.prev_page: (MonsterListMenu.respond_with_left, MonsterListView.VIEW_TYPE, None), + MonsterListEmoji.prev_mon: ( + MonsterListMenu.respond_with_previous_monster, None, MonsterListMenu.auto_scroll_child_left), + MonsterListEmoji.next_mon: ( + MonsterListMenu.respond_with_next_monster, None, MonsterListMenu.auto_scroll_child_right), + MonsterListEmoji.next_page: (MonsterListMenu.respond_with_right, MonsterListView.VIEW_TYPE, None), + MonsterListEmoji.zero: ( + MonsterListMenu.respond_with_0, IdView.VIEW_TYPE, MonsterListMenu.click_child_number), + MonsterListEmoji.one: ( + MonsterListMenu.respond_with_1, IdView.VIEW_TYPE, MonsterListMenu.click_child_number), + MonsterListEmoji.two: ( + MonsterListMenu.respond_with_2, IdView.VIEW_TYPE, MonsterListMenu.click_child_number), + MonsterListEmoji.three: ( + MonsterListMenu.respond_with_3, IdView.VIEW_TYPE, MonsterListMenu.click_child_number), + MonsterListEmoji.four: ( + MonsterListMenu.respond_with_4, IdView.VIEW_TYPE, MonsterListMenu.click_child_number), + MonsterListEmoji.five: ( + MonsterListMenu.respond_with_5, IdView.VIEW_TYPE, MonsterListMenu.click_child_number), + MonsterListEmoji.six: ( + MonsterListMenu.respond_with_6, IdView.VIEW_TYPE, MonsterListMenu.click_child_number), + MonsterListEmoji.seven: ( + MonsterListMenu.respond_with_7, IdView.VIEW_TYPE, MonsterListMenu.click_child_number), + MonsterListEmoji.eight: ( + MonsterListMenu.respond_with_8, IdView.VIEW_TYPE, MonsterListMenu.click_child_number), + MonsterListEmoji.nine: ( + MonsterListMenu.respond_with_9, IdView.VIEW_TYPE, MonsterListMenu.click_child_number), + MonsterListEmoji.ten: ( + MonsterListMenu.respond_with_10, IdView.VIEW_TYPE, MonsterListMenu.click_child_number), + MonsterListEmoji.refresh: ( + MonsterListMenu.respond_with_refresh, None, None), + MonsterListEmoji.reset: ( + MonsterListMenu.respond_with_reset, None, None) + } + HIDDEN_EMOJIS = [ + MonsterListEmoji.home, + MonsterListEmoji.refresh, + MonsterListEmoji.reset, + ] + + @classmethod + def get_initial_reaction_list(cls, number_of_evos: int): + return cls.emoji_names()[:number_of_evos + cls.NON_MONSTER_EMOJI_COUNT] + + @classmethod + def get_previous_reaction_list_num_monsters(cls, reaction_list: List): + return len(reaction_list) - cls.NON_MONSTER_EMOJI_COUNT + + @classmethod + def get_monster_index(cls, n: int): + return n - cls.NON_MONSTER_EMOJI_COUNT diff --git a/padinfo/menu/na_diff.py b/padinfo/menu/na_diff.py new file mode 100644 index 00000000..4907a3ac --- /dev/null +++ b/padinfo/menu/na_diff.py @@ -0,0 +1,100 @@ +from typing import Optional + +from discord import Message +from discordmenu.embed.emoji import DELETE_MESSAGE_EMOJI +from discordmenu.embed.transitions import EmbedTransition, EmbedMenuDefaultTransitions +from discordmenu.embed.wrapper import EmbedWrapper +from discordmenu.embed.menu import EmbedMenu +from tsutils.enums import Server +from tsutils.menu.components.panes import MenuPanes +from tsutils.menu.simple_text import SimpleTextMenu +from tsutils.menu.view.simple_text import SimpleTextView, SimpleTextViewState +from tsutils.query_settings.enums import ChildMenuType + +from padinfo.view.id import IdView, IdViewState + + +class NaDiffEmoji: + home = '\N{HOUSE BUILDING}' + na = '\N{REGIONAL INDICATOR SYMBOL LETTER U}\N{REGIONAL INDICATOR SYMBOL LETTER S}' + jp = '\N{REGIONAL INDICATOR SYMBOL LETTER J}\N{REGIONAL INDICATOR SYMBOL LETTER P}' + + +class NaDiffMenu: + MENU_TYPE = ChildMenuType.NaDiffMenu.name + + @staticmethod + def menu(initial_control=None): + if initial_control is None: + initial_control = NaDiffMenu.id_control + embed = EmbedMenu(NaDiffMenuPanes.transitions(), initial_control, + EmbedMenuDefaultTransitions( + delete_message=EmbedTransition(DELETE_MESSAGE_EMOJI, NaDiffMenu.respond_with_delete))) + return embed + + @staticmethod + async def respond_with_home(message: Optional[Message], ims, **data): + dbcog = data['dbcog'] + user_config = data['user_config'] + view_state = await IdViewState.deserialize(dbcog, user_config, ims) + if view_state.set_na_diff_invalid_message(ims): + new_view_state = await SimpleTextViewState.deserialize(dbcog, user_config, ims) + return NaDiffMenu.message_control(new_view_state) + return await NaDiffMenu.respond_with_na(message, ims, **data) + + @staticmethod + async def respond_with_na(message: Optional[Message], ims, **data): + return await NaDiffMenu.respond_with_id(message, ims, Server.NA, **data) + + @staticmethod + async def respond_with_jp(message: Optional[Message], ims, **data): + return await NaDiffMenu.respond_with_id(message, ims, Server.COMBINED, **data) + + @staticmethod + async def respond_with_id(message: Optional[Message], ims, server: Server, **data): + dbcog = data['dbcog'] + user_config = data['user_config'] + + view_state = await IdViewState.deserialize(dbcog, user_config, ims) + await view_state.set_server(dbcog, server) + control = NaDiffMenu.id_control(view_state) + return control + + @staticmethod + async def respond_with_delete(message: Optional[Message], ims, **data): + if not ims.get('is_child'): + return await message.delete() + if ims.get('idle_message'): + ims['menu_type'] = SimpleTextMenu.MENU_TYPE + ims['message'] = ims['idle_message'] + return await SimpleTextMenu.respond_with_message(message, ims, **data) + return await message.edit(embed=None) + + @staticmethod + def id_control(state: IdViewState): + if state is None: + return None + return EmbedWrapper( + IdView.embed(state), + state.reaction_list or NaDiffMenuPanes.emoji_names() + ) + + @staticmethod + def message_control(state: SimpleTextViewState): + if state is None: + return None + return EmbedWrapper( + SimpleTextView.embed(state), + state.reaction_list + ) + + +class NaDiffMenuPanes(MenuPanes): + DATA = { + NaDiffEmoji.home: (NaDiffMenu.respond_with_home, IdView.VIEW_TYPE), + NaDiffEmoji.na: (NaDiffMenu.respond_with_na, IdView.VIEW_TYPE), + NaDiffEmoji.jp: (NaDiffMenu.respond_with_jp, IdView.VIEW_TYPE), + } + HIDDEN_EMOJIS = [ + NaDiffEmoji.home, + ] diff --git a/padinfo/menu/scroll.py b/padinfo/menu/scroll.py new file mode 100644 index 00000000..c77e16b6 --- /dev/null +++ b/padinfo/menu/scroll.py @@ -0,0 +1,19 @@ +from typing import List + +from padinfo.menu.monster_list import MonsterListEmoji, MonsterListMenuPanes + + +class ScrollMenuPanes(MonsterListMenuPanes): + @classmethod + def get_initial_reaction_list(cls, number_of_evos: int): + return [MonsterListEmoji.prev_mon, MonsterListEmoji.next_mon] + + @classmethod + def get_previous_reaction_list_num_monsters(cls, reaction_list: List): + # we don't want any extra emojis + return 0 + + @classmethod + def get_monster_index(cls, n: int): + # this is unimportant because we don't use indices + return 0 diff --git a/padinfo/menu/series_scroll.py b/padinfo/menu/series_scroll.py new file mode 100644 index 00000000..b480f6b8 --- /dev/null +++ b/padinfo/menu/series_scroll.py @@ -0,0 +1,250 @@ +from typing import List, Optional + +from discord import Message +from discordmenu.embed.menu import EmbedMenu +from discordmenu.embed.wrapper import EmbedWrapper +from tsutils.emoji import char_to_emoji +from tsutils.menu.components.panes import MenuPanes, emoji_buttons + +from padinfo.menu.id import IdMenu, IdMenuEmoji, IdMenuPanes +from padinfo.view.id import IdView +from padinfo.view.series_scroll import SeriesScrollView, SeriesScrollViewState + + +class SeriesScrollEmoji: + delete = '\N{CROSS MARK}' + home = emoji_buttons['home'] + prev_page = '\N{BLACK LEFT-POINTING DOUBLE TRIANGLE}' + prev_mon = '\N{BLACK LEFT-POINTING TRIANGLE}' + next_mon = '\N{BLACK RIGHT-POINTING TRIANGLE}' + next_page = '\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE}' + zero = char_to_emoji('0') + one = char_to_emoji('1') + two = char_to_emoji('2') + three = char_to_emoji('3') + four = char_to_emoji('4') + five = char_to_emoji('5') + six = char_to_emoji('6') + seven = char_to_emoji('7') + eight = char_to_emoji('8') + nine = char_to_emoji('9') + ten = char_to_emoji('10') + refresh = '\N{ANTICLOCKWISE DOWNWARDS AND UPWARDS OPEN CIRCLE ARROWS}' + reset = emoji_buttons['reset'] + + +class SeriesScrollMenu: + MENU_TYPE = 'SeriesScrollMenu' + CHILD_MENU_TYPE = 'IdMenu' + SCROLL_INTERVAL = SeriesScrollViewState.MAX_ITEMS_PER_PANE + RARITY_INITIAL_TRY_ORDER = [6, 7, 8, 9, 10, 5, 4, 3, 11, 12, 2, 1, 13, 14] + SCROLL_INITIAL_POSITION = 0 + + @staticmethod + def menu(initial_control=None): + if initial_control is None: + initial_control = SeriesScrollMenu.monster_list_control + embed = EmbedMenu(SeriesScrollMenuPanes.transitions(), initial_control) + return embed + + @classmethod + async def _get_view_state(cls, ims: dict, **data) -> SeriesScrollViewState: + dbcog = data['dbcog'] + user_config = data['user_config'] + return await SeriesScrollViewState.deserialize(dbcog, user_config, ims) + + @staticmethod + async def respond_with_refresh(message: Optional[Message], ims, **data): + # this is only called once on message load + if data.get('child_message_id'): + ims['child_message_id'] = data['child_message_id'] + return await SeriesScrollMenu.respond_with_monster_list(message, ims, **data) + + @staticmethod + async def respond_with_reset(message: Optional[Message], ims, **data): + # replace with the overview list after the child menu changes + return await SeriesScrollMenu.respond_with_monster_list(message, ims, **data) + + @classmethod + async def respond_with_left(cls, message: Optional[Message], ims, **data): + dbcog = data['dbcog'] + view_state = await cls._get_view_state(ims, **data) + await view_state.decrement_page(dbcog) + return SeriesScrollMenu.monster_list_control(view_state) + + @classmethod + async def respond_with_right(cls, message: Optional[Message], ims, **data): + dbcog = data['dbcog'] + view_state = await cls._get_view_state(ims, **data) + await view_state.increment_page(dbcog) + return SeriesScrollMenu.monster_list_control(view_state) + + @classmethod + async def respond_with_monster_list(cls, message: Optional[Message], ims, **data): + view_state = await cls._get_view_state(ims, **data) + return SeriesScrollMenu.monster_list_control(view_state) + + @classmethod + async def respond_with_previous_monster(cls, message: Optional[Message], ims, **data): + dbcog = data['dbcog'] + view_state = await cls._get_view_state(ims, **data) + await view_state.decrement_index(dbcog) + return SeriesScrollMenu.monster_list_control(view_state) + + @classmethod + async def respond_with_next_monster(cls, message: Optional[Message], ims, **data): + dbcog = data['dbcog'] + view_state = await cls._get_view_state(ims, **data) + await view_state.increment_index(dbcog) + return SeriesScrollMenu.monster_list_control(view_state) + + @staticmethod + async def respond_with_delete(message: Message, ims, **data): + return await message.delete() + + @classmethod + async def respond_with_n(cls, message: Optional[Message], ims, n, **data): + view_state = await cls._get_view_state(ims, **data) + current_monster_list = view_state.paginated_monsters[view_state.current_page] + # ims will be immediately be deserialized and shown, so don't change if n is out of range + if n < len(current_monster_list): + ims['current_index'] = n + return await SeriesScrollMenu.respond_with_monster_list(message, ims, **data) + + @staticmethod + async def respond_with_0(message: Optional[Message], ims, **data): + return await SeriesScrollMenu.respond_with_n(message, ims, 0, **data) + + @staticmethod + async def respond_with_1(message: Optional[Message], ims, **data): + return await SeriesScrollMenu.respond_with_n(message, ims, 1, **data) + + @staticmethod + async def respond_with_2(message: Optional[Message], ims, **data): + return await SeriesScrollMenu.respond_with_n(message, ims, 2, **data) + + @staticmethod + async def respond_with_3(message: Optional[Message], ims, **data): + return await SeriesScrollMenu.respond_with_n(message, ims, 3, **data) + + @staticmethod + async def respond_with_4(message: Optional[Message], ims, **data): + return await SeriesScrollMenu.respond_with_n(message, ims, 4, **data) + + @staticmethod + async def respond_with_5(message: Optional[Message], ims, **data): + return await SeriesScrollMenu.respond_with_n(message, ims, 5, **data) + + @staticmethod + async def respond_with_6(message: Optional[Message], ims, **data): + return await SeriesScrollMenu.respond_with_n(message, ims, 6, **data) + + @staticmethod + async def respond_with_7(message: Optional[Message], ims, **data): + return await SeriesScrollMenu.respond_with_n(message, ims, 7, **data) + + @staticmethod + async def respond_with_8(message: Optional[Message], ims, **data): + return await SeriesScrollMenu.respond_with_n(message, ims, 8, **data) + + @staticmethod + async def respond_with_9(message: Optional[Message], ims, **data): + return await SeriesScrollMenu.respond_with_n(message, ims, 9, **data) + + @staticmethod + async def respond_with_10(message: Optional[Message], ims, **data): + return await SeriesScrollMenu.respond_with_n(message, ims, 10, **data) + + @staticmethod + def monster_list_control(state: SeriesScrollViewState): + return EmbedWrapper( + SeriesScrollView.embed(state), + SeriesScrollMenuPanes.get_initial_reaction_list(state.max_len_so_far) + ) + + @classmethod + async def click_child_number(cls, ims, emoji_clicked, **data): + emoji_response = IdMenuEmoji.refresh \ + if SeriesScrollMenuPanes.respond_to_emoji_with_child(emoji_clicked) else None + if emoji_response is None: + return None, {} + n = SeriesScrollMenuPanes.emoji_names().index(emoji_clicked) + view_state = await cls._get_view_state(ims, **data) + view_state.set_index(SeriesScrollMenuPanes.get_monster_index(n)) + extra_ims = view_state.get_serialized_child_extra_ims(IdMenuPanes.emoji_names(), IdMenu.MENU_TYPE) + return emoji_response, extra_ims + + @classmethod + async def auto_scroll_child_left(cls, ims, _emoji_clicked, **data): + dbcog = data['dbcog'] + view_state = await cls._get_view_state(ims, **data) + return await SeriesScrollMenu._scroll_child(view_state, view_state.decrement_index, dbcog) + + @classmethod + async def auto_scroll_child_right(cls, ims, _emoji_clicked, **data): + dbcog = data['dbcog'] + view_state = await cls._get_view_state(ims, **data) + return await SeriesScrollMenu._scroll_child(view_state, view_state.increment_index, dbcog) + + @staticmethod + async def _scroll_child(view_state: SeriesScrollViewState, update_fn, dbcog): + await update_fn(dbcog) + extra_ims = view_state.get_serialized_child_extra_ims(IdMenuPanes.emoji_names(), IdMenu.MENU_TYPE) + return IdMenuEmoji.refresh, extra_ims + + +class SeriesScrollMenuPanes(MenuPanes): + INITIAL_EMOJI = '\N{HOUSE BUILDING}' + NON_MONSTER_EMOJI_COUNT = 5 + DATA = { + # tuple parts: emoji, pane_type, respond_with_parent, respond_with_child + SeriesScrollEmoji.delete: (SeriesScrollMenu.respond_with_delete, None, None), + SeriesScrollEmoji.home: (SeriesScrollMenu.respond_with_monster_list, None, None), + SeriesScrollEmoji.prev_page: (SeriesScrollMenu.respond_with_left, SeriesScrollView.VIEW_TYPE, None), + SeriesScrollEmoji.prev_mon: ( + SeriesScrollMenu.respond_with_previous_monster, None, SeriesScrollMenu.auto_scroll_child_left), + SeriesScrollEmoji.next_mon: ( + SeriesScrollMenu.respond_with_next_monster, None, SeriesScrollMenu.auto_scroll_child_right), + SeriesScrollEmoji.next_page: (SeriesScrollMenu.respond_with_right, SeriesScrollView.VIEW_TYPE, None), + SeriesScrollEmoji.zero: ( + SeriesScrollMenu.respond_with_0, IdView.VIEW_TYPE, SeriesScrollMenu.click_child_number), + SeriesScrollEmoji.one: ( + SeriesScrollMenu.respond_with_1, IdView.VIEW_TYPE, SeriesScrollMenu.click_child_number), + SeriesScrollEmoji.two: ( + SeriesScrollMenu.respond_with_2, IdView.VIEW_TYPE, SeriesScrollMenu.click_child_number), + SeriesScrollEmoji.three: ( + SeriesScrollMenu.respond_with_3, IdView.VIEW_TYPE, SeriesScrollMenu.click_child_number), + SeriesScrollEmoji.four: ( + SeriesScrollMenu.respond_with_4, IdView.VIEW_TYPE, SeriesScrollMenu.click_child_number), + SeriesScrollEmoji.five: ( + SeriesScrollMenu.respond_with_5, IdView.VIEW_TYPE, SeriesScrollMenu.click_child_number), + SeriesScrollEmoji.six: ( + SeriesScrollMenu.respond_with_6, IdView.VIEW_TYPE, SeriesScrollMenu.click_child_number), + SeriesScrollEmoji.seven: ( + SeriesScrollMenu.respond_with_7, IdView.VIEW_TYPE, SeriesScrollMenu.click_child_number), + SeriesScrollEmoji.eight: ( + SeriesScrollMenu.respond_with_8, IdView.VIEW_TYPE, SeriesScrollMenu.click_child_number), + SeriesScrollEmoji.nine: ( + SeriesScrollMenu.respond_with_9, IdView.VIEW_TYPE, SeriesScrollMenu.click_child_number), + SeriesScrollEmoji.ten: ( + SeriesScrollMenu.respond_with_10, IdView.VIEW_TYPE, SeriesScrollMenu.click_child_number), + SeriesScrollEmoji.refresh: (SeriesScrollMenu.respond_with_refresh, None, None), + SeriesScrollEmoji.reset: (SeriesScrollMenu.respond_with_reset, None, None) + } + HIDDEN_EMOJIS = [ + SeriesScrollEmoji.home, + SeriesScrollEmoji.refresh, + SeriesScrollEmoji.reset, + ] + + @classmethod + def get_initial_reaction_list(cls, number_of_evos: int): + return cls.emoji_names()[:number_of_evos + cls.NON_MONSTER_EMOJI_COUNT] + + @classmethod + def get_previous_reaction_list_num_monsters(cls, reaction_list: List): + return len(reaction_list) - cls.NON_MONSTER_EMOJI_COUNT + + @classmethod + def get_monster_index(cls, n: int): + return n - cls.NON_MONSTER_EMOJI_COUNT diff --git a/padinfo/menu/transforminfo.py b/padinfo/menu/transforminfo.py new file mode 100644 index 00000000..9404a807 --- /dev/null +++ b/padinfo/menu/transforminfo.py @@ -0,0 +1,131 @@ +from typing import Optional + +from discord import Message +from discordmenu.embed.menu import EmbedMenu +from discordmenu.embed.wrapper import EmbedWrapper + +from tsutils.emoji import char_to_emoji +from tsutils.menu.components.panes import MenuPanes, emoji_buttons + +from padinfo.view.id import IdView, IdViewState +from padinfo.view.transforminfo import TransformInfoView, TransformInfoViewState + + +class TransformInfoMenu: + MENU_TYPE = 'TransformInfo' + + @staticmethod + def menu(): + return EmbedMenu(TransformInfoMenuPanes.transitions(), TransformInfoMenu.tf_control) + + @staticmethod + async def respond_with_base(message: Optional[Message], ims, **data): + dbcog = data['dbcog'] + user_config = data['user_config'] + + # base is always first + ims['resolved_monster_id'] = ims['resolved_monster_ids'][0] + id_view_state = await IdViewState.deserialize(dbcog, user_config, ims) + reaction_list = ims['reaction_list'] + id_control = TransformInfoMenu.id_control(id_view_state, reaction_list) + return id_control + + @staticmethod + async def respond_with_transform(message: Optional[Message], ims, **data): + dbcog = data['dbcog'] + user_config = data['user_config'] + + # transform is always second + ims['resolved_monster_id'] = ims['resolved_monster_ids'][1] + id_view_state = await IdViewState.deserialize(dbcog, user_config, ims) + reaction_list = ims['reaction_list'] + id_control = TransformInfoMenu.id_control(id_view_state, reaction_list) + return id_control + + @staticmethod + async def respond_with_n(message: Optional[Message], ims, **data): + dbcog = data['dbcog'] + user_config = data['user_config'] + n = TransformInfoMenuPanes.get_n_from_reaction(data['reaction']) + # does this work? is this check necessary? is reaction guaranteed to resolve? + if n is None: + return None + + ims['resolved_monster_id'] = ims['resolved_monster_ids'][n] + id_view_state = await IdViewState.deserialize(dbcog, user_config, ims) + reaction_list = ims['reaction_list'] + id_control = TransformInfoMenu.id_control(id_view_state, reaction_list) + return id_control + + @staticmethod + async def respond_with_overview(message: Optional[Message], ims, **data): + dbcog = data['dbcog'] + user_config = data['user_config'] + tf_view_state = await TransformInfoViewState.deserialize(dbcog, user_config, ims) + tf_control = TransformInfoMenu.tf_control(tf_view_state) + return tf_control + + @staticmethod + def tf_control(state: TransformInfoViewState): + reaction_list = state.reaction_list + return EmbedWrapper( + TransformInfoView.embed(state), + reaction_list + ) + + @staticmethod + def id_control(state: IdViewState, reaction_list): + return EmbedWrapper( + IdView.embed(state), + reaction_list + ) + + +class TransformInfoEmoji: + home = emoji_buttons['home'] + down = ('downr', '\N{DOWN-POINTING RED TRIANGLE}') + up = ('upgr', '\N{UP-POINTING RED TRIANGLE}') + one = char_to_emoji('1') + two = char_to_emoji('2') + three = char_to_emoji('3') + four = char_to_emoji('4') + five = char_to_emoji('5') + six = char_to_emoji('6') + seven = char_to_emoji('7') + eight = char_to_emoji('8') + nine = char_to_emoji('9') + ten = char_to_emoji('10') + + +class TransformInfoMenuPanes(MenuPanes): + DATA = { + TransformInfoEmoji.home: (TransformInfoMenu.respond_with_overview, + TransformInfoView.VIEW_TYPE), + TransformInfoEmoji.down: (TransformInfoMenu.respond_with_base, IdView.VIEW_TYPE), + TransformInfoEmoji.up: (TransformInfoMenu.respond_with_transform, IdView.VIEW_TYPE), + TransformInfoEmoji.one: (TransformInfoMenu.respond_with_n, IdView.VIEW_TYPE), + TransformInfoEmoji.two: (TransformInfoMenu.respond_with_n, IdView.VIEW_TYPE), + TransformInfoEmoji.three: (TransformInfoMenu.respond_with_n, IdView.VIEW_TYPE), + TransformInfoEmoji.four: (TransformInfoMenu.respond_with_n, IdView.VIEW_TYPE), + TransformInfoEmoji.five: (TransformInfoMenu.respond_with_n, IdView.VIEW_TYPE), + TransformInfoEmoji.six: (TransformInfoMenu.respond_with_n, IdView.VIEW_TYPE), + TransformInfoEmoji.seven: (TransformInfoMenu.respond_with_n, IdView.VIEW_TYPE), + TransformInfoEmoji.eight: (TransformInfoMenu.respond_with_n, IdView.VIEW_TYPE), + TransformInfoEmoji.nine: (TransformInfoMenu.respond_with_n, IdView.VIEW_TYPE), + TransformInfoEmoji.ten: (TransformInfoMenu.respond_with_n, IdView.VIEW_TYPE), + } + + @classmethod + def get_user_reaction_list(cls, number_of_monsters: int): + if number_of_monsters > 2: + cls.HIDDEN_EMOJIS = [TransformInfoEmoji.up] + else: + cls.HIDDEN_EMOJIS = [TransformInfoEmoji.one] + + # add 1 for the home emoji + return cls.emoji_names()[:number_of_monsters + 1] + + @classmethod + def get_n_from_reaction(cls, reaction): + # offset by 1 because of the home emoji + return cls.emoji_names().index(reaction) - 1 if reaction in cls.emoji_names() else None diff --git a/padinfo/padinfo.py b/padinfo/padinfo.py index 0d4cb347..d027f352 100644 --- a/padinfo/padinfo.py +++ b/padinfo/padinfo.py @@ -1,441 +1,915 @@ import asyncio +import csv +import io import json import logging import os -import traceback -import urllib.parse -from collections import OrderedDict - +import random +import re +from collections import defaultdict +from enum import EnumMeta +from io import BytesIO +from typing import Callable, List, Optional, TYPE_CHECKING, Type + +import aiohttp import discord -import prettytable -from redbot.core import checks, data_manager -from redbot.core import commands -from redbot.core.bot import Red -from redbot.core.utils.chat_formatting import inline, box - -import rpadutils -from rpadutils import char_to_emoji, Menu, EmojiUpdater, safe_read_json, CogSettings, rmdiacritics - -logger = logging.getLogger('red.miruv3.padinfo') - -HELP_MSG = """ -{0.prefix}helpid : shows this message -{0.prefix}id : look up a monster and print a link to puzzledragonx -{0.prefix}pic : Look up a monster and display its image inline - -Options for - : Find a monster by ID - {0.prefix}id 1234 (picks sun quan) - : Take the best guess for a monster, picks the most recent monster - {0.prefix}id kali (picks mega awoken d kali) - : Limit by element or awoken, e.g. - {0.prefix}id ares (selects the most recent, revo ares) - {0.prefix}id aares (explicitly selects awoken ares) - {0.prefix}id a ares (spaces work too) - {0.prefix}id rd ares (select a specific evo for ares, the red/dark one) - {0.prefix}id r/d ares (slashes, spaces work too) - -computed nickname list and overrides: https://docs.google.com/spreadsheets/d/1EyzMjvf8ZCQ4K-gJYnNkiZlCEsT9YYI9dUd-T5qCirc/pubhtml -submit an override suggestion: https://docs.google.com/forms/d/1kJH9Q0S8iqqULwrRqB9dSxMOMebZj6uZjECqi4t9_z0/edit""" +from discord import Color +from discordmenu.emoji.emoji_cache import emoji_cache +from redbot.core import Config, checks, commands, data_manager +from redbot.core.utils.chat_formatting import bold, box, inline, pagify +from tsutils.cogs.donations import is_donor +from tsutils.cogs.globaladmin import auth_check +from tsutils.emoji import char_to_emoji +from tsutils.enums import Server +from tsutils.json_utils import safe_read_json +from tsutils.menu.components.config import BotConfig +from tsutils.menu.simple_text import SimpleTextMenu +from tsutils.menu.view.closable_embed import ClosableEmbedViewState +from tsutils.menu.view.simple_text import SimpleTextViewState +from tsutils.query_settings import converters +from tsutils.query_settings.enums import AltEvoSort, CardLevelModifier, CardModeModifier, CardPlusModifier, EvoGrouping, \ + LsMultiplier, \ + MonsterLinkTarget, SkillDisplay +from tsutils.query_settings.query_settings import QuerySettings +from tsutils.tsubaki.custom_emoji import AWAKENING_ID_TO_EMOJI_NAME_MAP, get_attribute_emoji_by_enum, \ + get_attribute_emoji_by_monster, get_awakening_emoji, get_type_emoji +from tsutils.tsubaki.monster_header import MonsterHeader +from tsutils.user_interaction import send_cancellation_message, send_confirmation_message + +from padinfo.board_generator import BoardGenerator +from padinfo.core.button_info import button_info +from padinfo.core.leader_skills import leaderskill_query +from padinfo.core.padinfo_settings import settings +from padinfo.core.transforminfo import perform_transforminfo_query +from padinfo.menu.awakening_list import AwakeningListMenu, AwakeningListMenuPanes +from padinfo.menu.button_info import ButtonInfoMenu, ButtonInfoMenuPanes +from padinfo.menu.closable_embed import ClosableEmbedMenu +from padinfo.menu.id import IdMenu, IdMenuPanes +from padinfo.menu.leader_skill import LeaderSkillMenu +from padinfo.menu.leader_skill_single import LeaderSkillSingleMenu +from padinfo.menu.menu_map import padinfo_menu_map +from padinfo.menu.monster_list import MonsterListEmoji, MonsterListMenu, MonsterListMenuPanes +from padinfo.menu.na_diff import NaDiffMenu, NaDiffMenuPanes +from padinfo.menu.scroll import ScrollMenuPanes +from padinfo.menu.series_scroll import SeriesScrollEmoji, SeriesScrollMenu, SeriesScrollMenuPanes +from padinfo.menu.transforminfo import TransformInfoMenu, TransformInfoMenuPanes +from padinfo.reaction_list import get_id_menu_initial_reaction_list +from padinfo.view.awakening_help import AwakeningHelpView, AwakeningHelpViewProps +from padinfo.view.awakening_list import AwakeningListSortTypes, AwakeningListViewState +from padinfo.view.button_info import ButtonInfoToggles, ButtonInfoViewState +from padinfo.view.common import invalid_monster_text +from padinfo.view.dungeon_list.jp_dungeon_name import JpDungeonNameViewProps, JpDungeonNameView +from padinfo.view.dungeon_list.jpytdglead import JpYtDgLeadProps, JpYtDgLeadView +from padinfo.view.dungeon_list.skyo_links import SkyoLinksView, SkyoLinksViewProps +from padinfo.view.evos import EvosViewState +from padinfo.view.experience_curve import ExperienceCurveView, ExperienceCurveViewProps +from padinfo.view.id import IdViewState +from padinfo.view.id_traceback import IdTracebackView, IdTracebackViewProps +from padinfo.view.leader_skill import LeaderSkillViewState +from padinfo.view.leader_skill_single import LeaderSkillSingleViewState +from padinfo.view.links import LinksView +from padinfo.view.lookup import LookupView +from padinfo.view.materials import MaterialsViewState +from padinfo.view.monster_list.all_mats import AllMatsViewState +from padinfo.view.monster_list.id_search import IdSearchViewState +from padinfo.view.monster_list.monster_list import MonsterListViewState, MonsterListQueriedProps +from padinfo.view.monster_list.scroll import ScrollViewState +from padinfo.view.monster_list.static_monster_list import StaticMonsterListViewState +from padinfo.view.otherinfo import OtherInfoViewState +from padinfo.view.pantheon import PantheonViewState +from padinfo.view.pic import PicViewState +from padinfo.view.series_scroll import SeriesScrollViewState +from padinfo.view.transforminfo import TransformInfoViewState + +if TYPE_CHECKING: + from dbcog.dbcog import DBCog + from dbcog.database_manager import DBCogDatabase + from dbcog.models.monster_model import MonsterModel + from dbcog.models.series_model import SeriesModel + +logger = logging.getLogger('red.padbot-cogs.padinfo') EMBED_NOT_GENERATED = -1 -INFO_PDX_TEMPLATE = 'http://www.puzzledragonx.com/en/monster.asp?n={}' - -MEDIA_PATH = 'https://f002.backblazeb2.com/file/dadguide-data/media/' -RPAD_PIC_TEMPLATE = MEDIA_PATH + 'portraits/{0:05d}.png' -RPAD_PORTRAIT_TEMPLATE = MEDIA_PATH + 'icons/{0:05d}.png' -VIDEO_TEMPLATE = MEDIA_PATH + 'animated_portraits/{0:05d}.mp4' -GIF_TEMPLATE = MEDIA_PATH + 'animated_portraits/{0:05d}.gif' -ORB_SKIN_TEMPLATE = MEDIA_PATH + 'orb_skins/{0:03d}.png' -ORB_SKIN_CB_TEMPLATE = MEDIA_PATH + 'orb_skins/{0:03d}cb.png' - -YT_SEARCH_TEMPLATE = 'https://www.youtube.com/results?search_query={}' -SKYOZORA_TEMPLATE = 'http://pad.skyozora.com/pets/{}' - +IDGUIDE = "https://github.com/TsubakiBotPad/pad-cogs/wiki/id-user-guide" -def get_pdx_url(m): - return INFO_PDX_TEMPLATE.format(rpadutils.get_pdx_id(m)) +DUNGEON_ALIASES = "https://docs.google.com/spreadsheets/d/e/" \ + "2PACX-1vQ3F4shS6w2na4FXA-vZyyhKcOQ0zRA1B3T7zaX0Bm4cEjW-1IVw91josPtLgc9Zh_TGh8GTD6zFmd0" \ + "/pub?gid=0&single=true&output=csv" - -def get_portrait_url(m): - return RPAD_PORTRAIT_TEMPLATE.format(m.monster_id) - - -def get_pic_url(m): - return RPAD_PIC_TEMPLATE.format(m.monster_id) +HISTORY_DURATION = 11 def _data_file(file_name: str) -> str: return os.path.join(str(data_manager.cog_data_path(raw_name='padinfo')), file_name) -class IdEmojiUpdater(EmojiUpdater): - def __init__(self, emoji_to_embed, m: "DgMonster" = None, - pad_info=None, selected_emoji=None, bot=None): - self.emoji_dict = emoji_to_embed - self.m = m - self.pad_info = pad_info - self.selected_emoji = selected_emoji - self.bot = bot - - def on_update(self, ctx, selected_emoji): - if self.pad_info.settings.checkEvoID(ctx.author.id): - DGCOG = self.bot.get_cog('Dadguide') - evos = list(map(lambda x: [x['from_id'], x['to_id']], - DGCOG.database.get_all_evolutions_by_monster(self.m.monster_id))) - evos = list(set(sum(evos, []))) - evos.sort() - index = evos.index(self.m.monster_id) - if selected_emoji == self.pad_info.previous_monster_emoji: - if index == 0: - self.m = DGCOG.get_monster_by_id(evos[-1]) - else: - self.m = DGCOG.get_monster_by_id(evos[index - 1]) - elif selected_emoji == self.pad_info.next_monster_emoji: - if index == len(evos) - 1: - self.m = DGCOG.get_monster_by_id(evos[0]) - else: - self.m = DGCOG.get_monster_by_id(evos[index + 1]) - else: - self.selected_emoji = selected_emoji - return True - else: - if selected_emoji == self.pad_info.previous_monster_emoji: - if self.m.prev_monster is None: - return False - self.m = self.m.prev_monster - elif selected_emoji == self.pad_info.next_monster_emoji: - if self.m.next_monster is None: - return False - self.m = self.m.next_monster - else: - self.selected_emoji = selected_emoji - return True - self.emoji_dict = self.pad_info.get_id_emoji_options(m=self.m) - return True +class PadInfo(commands.Cog): + """Info for PAD Cards""" + menu_map = padinfo_menu_map -class PadInfo(commands.Cog): - def __init__(self, bot: Red, *args, **kwargs): + def __init__(self, bot, *args, **kwargs): super().__init__(*args, **kwargs) self.bot = bot - self.settings = PadInfoSettings("padinfo") - - self.index_all = None - self.index_na = None - - self.menu = Menu(bot) - # These emojis are the keys into the idmenu submenus - self.id_emoji = '\N{INFORMATION SOURCE}' - self.evo_emoji = char_to_emoji('e') - self.mats_emoji = char_to_emoji('m') - self.ls_emoji = '\N{INFORMATION SOURCE}' + self.ls_emoji = '\N{HOUSE BUILDING}' self.left_emoji = char_to_emoji('l') self.right_emoji = char_to_emoji('r') - self.pantheon_emoji = '\N{CLASSICAL BUILDING}' - self.skillups_emoji = '\N{MEAT ON BONE}' - self.pic_emoji = '\N{FRAME WITH PICTURE}' - self.other_info_emoji = '\N{SCROLL}' - self.previous_monster_emoji = '\N{HEAVY MINUS SIGN}' - self.next_monster_emoji = '\N{HEAVY PLUS SIGN}' - self.remove_emoji = self.menu.emoji['no'] - - self.historic_lookups_file_path = _data_file('historic_lookups.json') + self.remove_emoji = '\N{CROSS MARK}' + + self.config = Config.get_conf(self, identifier=9401770) + self.config.register_user(color=None, beta_id3=False, id_history=[]) + self.config.register_global(sometimes_perc=20, good=0, bad=0, bad_queries=[]) + + self.historic_lookups_file_path = _data_file('historic_lookups_id3.json') self.historic_lookups = safe_read_json(self.historic_lookups_file_path) - self.historic_lookups_file_path_id2 = _data_file('historic_lookups_id2.json') - self.historic_lookups_id2 = safe_read_json(self.historic_lookups_file_path_id2) + self.awoken_emoji_names = {v: k for k, v in AWAKENING_ID_TO_EMOJI_NAME_MAP.items()} + self.get_attribute_emoji_by_monster = get_attribute_emoji_by_monster + self.settings = settings + + self.aliases = defaultdict(set) + self.aliases_loaded = asyncio.Event() + + async def load_aliases(self): + self.aliases_loaded.clear() + self.aliases = defaultdict(set) + async with aiohttp.ClientSession() as session: + async with session.get(DUNGEON_ALIASES) as response: + reader = csv.reader(io.StringIO(await response.text()), delimiter=',') + next(reader) + for line in reader: + for a in line[2].replace(' ', '').split(','): + self.aliases[a].add(line[0] or line[1]) + self.aliases_loaded.set() + + async def red_get_data_for_user(self, *, user_id): + """Get a user's personal data.""" + idhist = await self.config.user_from_id(user_id).id_history() + if idhist: + data = f"You have {len(idhist)} past queries stored. Use" \ + f" {(await self.bot.get_valid_prefixes())[0]}idhistory to see what they are.\n" + else: + data = f"No data is stored for user with ID {user_id}." + return {"user_data.txt": BytesIO(data.encode())} + + async def red_delete_data_for_user(self, *, requester, user_id): + """Delete a user's personal data. - def cog_unload(self): - # Manually nulling out database because the GC for cogs seems to be pretty shitty - self.index_all = None - self.index_na = None - self.historic_lookups = {} - self.historic_lookups_id2 = {} + No personal data is stored in this cog. + """ + await self.config.user_from_id(user_id).clear() + + async def register_menu(self): + await self.bot.wait_until_ready() + menulistener = self.bot.get_cog("MenuListener") + if menulistener is None: + logger.warning("MenuListener is not loaded.") + return + await menulistener.register(self) async def reload_nicknames(self): await self.bot.wait_until_ready() while self == self.bot.get_cog('PadInfo'): + wait_time = 60 * 60 * 1 try: - await self.refresh_index() - print('Done refreshing PadInfo') + emoji_cache.set_guild_ids([g.id for g in self.bot.guilds]) + emoji_cache.refresh_from_discord_bot(self.bot) except Exception as ex: - print("reload padinfo loop caught exception " + str(ex)) - traceback.print_exc() - - await asyncio.sleep(60 * 60 * 1) - - @commands.command() - @checks.is_owner() - async def refresh_index(self, ctx=None): - """Refresh the monster indexes.""" - dg_cog = self.bot.get_cog('Dadguide') - if not dg_cog: - print("Cog 'Dadguide' not loaded") - return - logger.info('Waiting until DG is ready') - await dg_cog.wait_until_ready() - - # Putting some sleeps in here to give the bot time to respond to Discord. - await asyncio.sleep(1) + wait_time = 5 + logger.exception("reload padinfo loop caught exception " + str(ex)) - logger.info('Loading ALL index') - self.index_all = dg_cog.create_index() - await asyncio.sleep(1) + await asyncio.sleep(wait_time) - logger.info('Loading NA index') - self.index_na = dg_cog.create_index(lambda m: m.on_na) - await asyncio.sleep(1) + async def get_dbcog(self) -> "DBCog": + dbcog = self.bot.get_cog("DBCog") + if dbcog is None: + raise ValueError("DBCog cog is not loaded") + await dbcog.wait_until_ready() + return dbcog - logger.info('Done refreshing indexes') + def save_historic_data(self, query, monster: Optional["MonsterModel"]): + self.historic_lookups[query] = monster.monster_id if monster else -1 + json.dump(self.historic_lookups, open(self.historic_lookups_file_path, "w+")) - def get_monster_by_id(self, monster_id: int): - dg_cog = self.bot.get_cog('Dadguide') - return dg_cog.get_monster_by_id(monster_id) + async def get_menu_default_data(self, ims): + data = { + 'dbcog': await self.get_dbcog(), + 'user_config': await BotConfig.get_user(self.config, ims['original_author_id']) + } + return data @commands.command() async def jpname(self, ctx, *, query: str): - """Print the Japanese name of a monster""" - m, err, debug_info = self.findMonster(query) - if m is not None: - await ctx.send(monsterToHeader(m)) - await ctx.send(box(m.name_jp)) + """Show the Japanese name of a monster""" + dbcog = await self.get_dbcog() + monster = await dbcog.find_monster(query, ctx.author.id) + if monster is not None: + await ctx.send(MonsterHeader.text_with_emoji(monster)) + await ctx.send(box(monster.name_ja)) else: - await ctx.send(self.makeFailureMsg(err)) + await self.send_id_failure_message(ctx, query) - @commands.command(name="id", aliases="iD Id ID".split()) + @commands.command(name="id", aliases=['I\'d', 'ID']) + @checks.bot_has_permissions(embed_links=True) async def _id(self, ctx, *, query: str): """Monster info (main tab)""" - prefix = ctx.prefix + "id" - query = prefix.join(filter(None, query.split(prefix))) await self._do_id(ctx, query) @commands.command() + @checks.bot_has_permissions(embed_links=True) async def idna(self, ctx, *, query: str): """Monster info (limited to NA monsters ONLY)""" - await self._do_id(ctx, query, na_only=True) + await self._do_id(ctx, "--na " + query) - async def _do_id(self, ctx, query: str, na_only=False): - m, err, debug_info = self.findMonster(query, na_only=na_only) - if m is not None: - await self._do_idmenu(ctx, m, self.id_emoji) - else: - await ctx.send(self.makeFailureMsg(err)) + @commands.command() + @checks.bot_has_permissions(embed_links=True) + async def idjp(self, ctx, *, query: str): + """Monster info (limited to JP monsters ONLY)""" + await self._do_id(ctx, "injp " + query) @commands.command() - async def id2(self, ctx, *, query: str): + @checks.bot_has_permissions(embed_links=True) + async def id3(self, ctx, *, query: str): """Monster info (main tab)""" - await self._do_id2(ctx, query) + await self._do_id(ctx, query) + + async def _get_monster(self, ctx, query) -> Optional["MonsterModel"]: + dbcog = await self.get_dbcog() + + monster = await dbcog.find_monster(query, ctx.author.id) + + if monster is None: + await self.send_id_failure_message(ctx, query) + return monster + + async def _do_id(self, ctx, query: str): + dbcog = await self.get_dbcog() + original_author_id = ctx.message.author.id + raw_query = query + + if (monster := await self._get_monster(ctx, query)) is None: + self.save_historic_data(query, monster) + return + + # id3 messaging stuff + if monster and monster.monster_no_na != monster.monster_no_jp: + await ctx.send(f"The NA ID and JP ID of this card differ! The JP ID is {monster.monster_id}, so " + f"you can query with {ctx.prefix}id jp{monster.monster_id}." + + (" Make sure you use the **JP id number** when updating the Google doc!!!!!" if + ctx.author.id in self.bot.get_cog("PadGlobal").settings.bot_settings['admins'] else "")) + + alt_monsters = IdViewState.get_alt_monsters_and_evos(dbcog, monster) + id_queried_props = await IdViewState.do_query(dbcog, monster) + full_reaction_list = IdMenuPanes.emoji_names() + initial_reaction_list = await get_id_menu_initial_reaction_list(ctx, dbcog, monster, full_reaction_list) + is_jp_buffed = dbcog.database.graph.monster_is_discrepant(monster) + query_settings = await QuerySettings.extract_raw(ctx.author, self.bot, query) + + state = IdViewState(original_author_id, IdMenu.MENU_TYPE, raw_query, query, monster, + alt_monsters, is_jp_buffed, query_settings, id_queried_props, + reaction_list=initial_reaction_list) + menu = IdMenu.menu() + await menu.create(ctx, state) + await self.log_id_result(ctx, monster.monster_id) + self.save_historic_data(query, monster) + + @staticmethod + async def send_invalid_monster_message(ctx, query: str, monster: "MonsterModel", append_text: str): + await ctx.send(invalid_monster_text(query, monster, append_text)) @commands.command() - async def id2na(self, ctx, *, query: str): - """Monster info (limited to NA monsters ONLY)""" - await self._do_id2(ctx, query, na_only=True) + @checks.bot_has_permissions(embed_links=True) + async def nadiff(self, ctx, *, query: str): + """Show differences between NA & JP versions of a card""" + dbcog = await self.get_dbcog() + raw_query = query + original_author_id = ctx.message.author.id + + monster = await dbcog.find_monster(raw_query, ctx.author.id) + if monster is None: + await self.send_id_failure_message(ctx, query) + self.save_historic_data(query, monster) + return - async def _do_id2(self, ctx, query: str, na_only=False): - m, err, debug_info = self.findMonster2(query, na_only=na_only) - if m is not None: - await self._do_idmenu(ctx, m, self.id_emoji) - else: - await ctx.send(self.makeFailureMsg(err)) + alt_monsters = IdViewState.get_alt_monsters_and_evos(dbcog, monster) + id_queried_props = await IdViewState.do_query(dbcog, monster) + + is_jp_buffed = dbcog.database.graph.monster_is_discrepant(monster) + query_settings = await QuerySettings.extract_raw(ctx.author, self.bot, query) + + state = IdViewState(original_author_id, NaDiffMenu.MENU_TYPE, raw_query, query, monster, + alt_monsters, is_jp_buffed, query_settings, id_queried_props) + menu = NaDiffMenu.menu() + message = state.get_na_diff_invalid_message() + if message: + state = SimpleTextViewState(original_author_id, NaDiffMenu.MENU_TYPE, + raw_query, query_settings, message) + menu = NaDiffMenu.menu(initial_control=NaDiffMenu.message_control) + await menu.create(ctx, state) + await self.log_id_result(ctx, monster.monster_id) + self.save_historic_data(query, monster) @commands.command(name="evos") + @checks.bot_has_permissions(embed_links=True) async def evos(self, ctx, *, query: str): """Monster info (evolutions tab)""" - m, err, debug_info = self.findMonster(query) - if m is not None: - await self._do_idmenu(ctx, m, self.evo_emoji) - else: - await ctx.send(self.makeFailureMsg(err)) + dbcog = await self.get_dbcog() + raw_query = query + original_author_id = ctx.message.author.id - @commands.command(name="mats", aliases=['evomats', 'evomat']) + monster = await dbcog.find_monster(raw_query, ctx.author.id) + + if monster is None: + await self.send_id_failure_message(ctx, query) + self.save_historic_data(query, monster) + return + + alt_monsters = EvosViewState.get_alt_monsters_and_evos(dbcog, monster) + alt_versions, gem_versions = await EvosViewState.do_query(dbcog, monster) + is_jp_buffed = dbcog.database.graph.monster_is_discrepant(monster) + query_settings = await QuerySettings.extract_raw(ctx.author, self.bot, query) + + if alt_versions is None: + await self.send_invalid_monster_message(ctx, query, monster, ', which has no alt evos or gems') + return + + full_reaction_list = IdMenuPanes.emoji_names() + initial_reaction_list = await get_id_menu_initial_reaction_list(ctx, dbcog, monster, full_reaction_list) + + state = EvosViewState(original_author_id, IdMenu.MENU_TYPE, raw_query, query, monster, + alt_monsters, is_jp_buffed, query_settings, + alt_versions, gem_versions, + reaction_list=initial_reaction_list + ) + menu = IdMenu.menu(initial_control=IdMenu.evos_control) + await menu.create(ctx, state) + await self.log_id_result(ctx, monster.monster_id) + self.save_historic_data(query, monster) + + @commands.command(name="mats", aliases=['evomats', 'evomat', 'skillups']) + @checks.bot_has_permissions(embed_links=True) async def evomats(self, ctx, *, query: str): """Monster info (evo materials tab)""" - m, err, debug_info = self.findMonster(query) - if m is not None: - await self._do_idmenu(ctx, m, self.mats_emoji) - else: - await ctx.send(self.makeFailureMsg(err)) + dbcog = await self.get_dbcog() + raw_query = query + original_author_id = ctx.message.author.id + monster = await dbcog.find_monster(raw_query, ctx.author.id) + + if not monster: + await self.send_id_failure_message(ctx, query) + self.save_historic_data(query, monster) + return - @commands.command() + mats, usedin, gemid, gemusedin, skillups, skillup_evo_count, link, gem_override = \ + await MaterialsViewState.do_query(dbcog, monster) + + if mats is None: + await ctx.send(inline("This monster has no mats or skillups and isn't used in any evolutions")) + return + + alt_monsters = MaterialsViewState.get_alt_monsters_and_evos(dbcog, monster) + is_jp_buffed = dbcog.database.graph.monster_is_discrepant(monster) + query_settings = await QuerySettings.extract_raw(ctx.author, self.bot, query) + full_reaction_list = IdMenuPanes.emoji_names() + initial_reaction_list = await get_id_menu_initial_reaction_list(ctx, dbcog, monster, full_reaction_list) + + state = MaterialsViewState(original_author_id, IdMenu.MENU_TYPE, raw_query, query, monster, + alt_monsters, is_jp_buffed, query_settings, + mats, usedin, gemid, gemusedin, skillups, skillup_evo_count, link, gem_override, + reaction_list=initial_reaction_list + ) + menu = IdMenu.menu(initial_control=IdMenu.mats_control) + await menu.create(ctx, state) + await self.log_id_result(ctx, monster.monster_id) + self.save_historic_data(query, monster) + + @commands.command(aliases=["series", "panth"]) + @checks.bot_has_permissions(embed_links=True) async def pantheon(self, ctx, *, query: str): """Monster info (pantheon tab)""" - m, err, debug_info = self.findMonster(query) - if m is not None: - menu = await self._do_idmenu(ctx, m, self.pantheon_emoji) - if menu == EMBED_NOT_GENERATED: - await ctx.send(inline('Not a pantheon monster')) - else: - await ctx.send(self.makeFailureMsg(err)) + dbcog = await self.get_dbcog() + raw_query = query + original_author_id = ctx.message.author.id - @commands.command() - async def skillups(self, ctx, *, query: str): - """Monster info (evolutions tab)""" - m, err, debug_info = self.findMonster(query) - if m is not None: - menu = await self._do_idmenu(ctx, m, self.skillups_emoji) - if menu == EMBED_NOT_GENERATED: - await ctx.send(inline('No skillups available')) - else: - await ctx.send(self.makeFailureMsg(err)) + monster = await dbcog.find_monster(raw_query, ctx.author.id) - async def _do_idmenu(self, ctx, m, starting_menu_emoji): - emoji_to_embed = self.get_id_emoji_options(m=m) - return await self._do_menu( - ctx, - starting_menu_emoji, - IdEmojiUpdater(emoji_to_embed, pad_info=self, - m=m, selected_emoji=starting_menu_emoji, bot=self.bot) - ) - - def get_id_emoji_options(self, m=None): - id_embed = monsterToEmbed(m, self.get_emojis()) - evo_embed = monsterToEvoEmbed(m) - mats_embed = monsterToEvoMatsEmbed(m) - animated = m.has_animation - pic_embed = monsterToPicEmbed(m, animated=animated) - other_info_embed = monsterToOtherInfoEmbed(m) - - emoji_to_embed = OrderedDict() - emoji_to_embed[self.id_emoji] = id_embed - emoji_to_embed[self.evo_emoji] = evo_embed - emoji_to_embed[self.mats_emoji] = mats_embed - emoji_to_embed[self.pic_emoji] = pic_embed - pantheon_embed = monsterToPantheonEmbed(m) - if pantheon_embed: - emoji_to_embed[self.pantheon_emoji] = pantheon_embed - - skillups_embed = monsterToSkillupsEmbed(m) - if skillups_embed: - emoji_to_embed[self.skillups_emoji] = skillups_embed - - emoji_to_embed[self.other_info_emoji] = other_info_embed - - # it's impossible for the previous/next ones to be accessed because - # IdEmojiUpdater won't allow it, however they have to be defined - # so that the buttons print in the first place - - emoji_to_embed[self.previous_monster_emoji] = None - emoji_to_embed[self.next_monster_emoji] = None - - # remove emoji needs to be last - emoji_to_embed[self.remove_emoji] = self.menu.reaction_delete_message - return emoji_to_embed - - async def _do_evolistmenu(self, ctx, sm): - monsters = sm.alt_evos - monsters.sort(key=lambda m: m.monster_id) - - emoji_to_embed = OrderedDict() - for idx, m in enumerate(monsters): - emoji = char_to_emoji(str(idx)) - emoji_to_embed[emoji] = monsterToEmbed(m, self.get_emojis()) - if m.monster_id == sm.monster_id: - starting_menu_emoji = emoji - - return await self._do_menu(ctx, starting_menu_emoji, EmojiUpdater(emoji_to_embed), timeout=60) - - async def _do_menu(self, ctx, starting_menu_emoji, emoji_to_embed, timeout=30): - if starting_menu_emoji not in emoji_to_embed.emoji_dict: - # Selected menu wasn't generated for this monster - return EMBED_NOT_GENERATED - - emoji_to_embed.emoji_dict[self.remove_emoji] = self.menu.reaction_delete_message + if monster is None: + await self.send_id_failure_message(ctx, query) + self.save_historic_data(query, monster) + return - try: - result_msg, result_embed = await self.menu.custom_menu(ctx, emoji_to_embed, - starting_menu_emoji, timeout=timeout) - if result_msg and result_embed: - # Message is finished but not deleted, clear the footer - result_embed.set_footer(text=discord.Embed.Empty) - await result_msg.edit(embed=result_embed) - except Exception as ex: - print('Menu failure', ex) + pantheon_list, series_name, base_monster = await PantheonViewState.do_query(dbcog, monster) + if pantheon_list is None: + await ctx.send('Unable to find a pantheon for the result of your query,' + + ' [{}] {}.'.format(monster.monster_id, monster.name_en)) + return + alt_monsters = PantheonViewState.get_alt_monsters_and_evos(dbcog, monster) + is_jp_buffed = dbcog.database.graph.monster_is_discrepant(monster) + query_settings = await QuerySettings.extract_raw(ctx.author, self.bot, query) + full_reaction_list = IdMenuPanes.emoji_names() + initial_reaction_list = await get_id_menu_initial_reaction_list(ctx, dbcog, monster, full_reaction_list) + + state = PantheonViewState(original_author_id, IdMenu.MENU_TYPE, raw_query, query, monster, + alt_monsters, is_jp_buffed, query_settings, + pantheon_list, series_name, base_monster, + reaction_list=initial_reaction_list + ) + menu = IdMenu.menu(initial_control=IdMenu.pantheon_control) + await menu.create(ctx, state) + await self.log_id_result(ctx, monster.monster_id) + self.save_historic_data(query, monster) @commands.command(aliases=['img']) + @checks.bot_has_permissions(embed_links=True) async def pic(self, ctx, *, query: str): """Monster info (full image tab)""" - m, err, debug_info = self.findMonster(query) - if m is not None: - await self._do_idmenu(ctx, m, self.pic_emoji) - else: - await ctx.send(self.makeFailureMsg(err)) + dbcog = await self.get_dbcog() + raw_query = query + original_author_id = ctx.message.author.id + + monster = await dbcog.find_monster(raw_query, ctx.author.id) + + if monster is None: + await self.send_id_failure_message(ctx, query) + self.save_historic_data(query, monster) + return + + alt_monsters = PicViewState.get_alt_monsters_and_evos(dbcog, monster) + is_jp_buffed = dbcog.database.graph.monster_is_discrepant(monster) + query_settings = await QuerySettings.extract_raw(ctx.author, self.bot, query) + full_reaction_list = IdMenuPanes.emoji_names() + initial_reaction_list = await get_id_menu_initial_reaction_list(ctx, dbcog, monster, full_reaction_list) + + state = PicViewState(original_author_id, IdMenu.MENU_TYPE, raw_query, query, monster, + alt_monsters, is_jp_buffed, query_settings, + reaction_list=initial_reaction_list + ) + menu = IdMenu.menu(initial_control=IdMenu.pic_control) + await menu.create(ctx, state) + await self.log_id_result(ctx, monster.monster_id) + self.save_historic_data(query, monster) @commands.command(aliases=['stats']) + @checks.bot_has_permissions(embed_links=True) async def otherinfo(self, ctx, *, query: str): """Monster info (misc info tab)""" - m, err, debug_info = self.findMonster(query) - if m is not None: - await self._do_idmenu(ctx, m, self.other_info_emoji) - else: - await ctx.send(self.makeFailureMsg(err)) + dbcog = await self.get_dbcog() + raw_query = query + original_author_id = ctx.message.author.id + + monster = await dbcog.find_monster(raw_query, ctx.author.id) + + if monster is None: + await self.send_id_failure_message(ctx, query) + self.save_historic_data(query, monster) + return + + alt_monsters = PicViewState.get_alt_monsters_and_evos(dbcog, monster) + is_jp_buffed = dbcog.database.graph.monster_is_discrepant(monster) + query_settings = await QuerySettings.extract_raw(ctx.author, self.bot, query) + full_reaction_list = IdMenuPanes.emoji_names() + initial_reaction_list = await get_id_menu_initial_reaction_list(ctx, dbcog, monster, full_reaction_list) + + state = OtherInfoViewState(original_author_id, IdMenu.MENU_TYPE, raw_query, query, monster, + alt_monsters, is_jp_buffed, query_settings, + reaction_list=initial_reaction_list + ) + menu = IdMenu.menu(initial_control=IdMenu.otherinfo_control) + await menu.create(ctx, state) + await self.log_id_result(ctx, monster.monster_id) + self.save_historic_data(query, monster) @commands.command() + @checks.bot_has_permissions(embed_links=True) + async def links(self, ctx, *, query: str): + """Monster links""" + dbcog = await self.get_dbcog() + monster = await dbcog.find_monster(query, ctx.author.id) + if monster is None: + await self.send_id_failure_message(ctx, query) + self.save_historic_data(query, monster) + return + await self.log_id_result(ctx, monster.monster_id) + query_settings = await QuerySettings.extract_raw(ctx.author, self.bot, query) + embed = LinksView.embed(monster, query_settings).to_embed() + await ctx.send(embed=embed) + await self.log_id_result(ctx, monster.monster_id) + self.save_historic_data(query, monster) + + @commands.command(aliases=['lu']) + @checks.bot_has_permissions(embed_links=True) async def lookup(self, ctx, *, query: str): """Short info results for a monster query""" - m, err, debug_info = self.findMonster(query) - if m is not None: - embed = monsterToHeaderEmbed(m) - await ctx.send(embed=embed) - else: - await ctx.send(self.makeFailureMsg(err)) + dbcog = await self.get_dbcog() + monster = await dbcog.find_monster(query, ctx.author.id) + if monster is None: + await self.send_id_failure_message(ctx, query) + self.save_historic_data(query, monster) + return + await self.log_id_result(ctx, monster.monster_id) + query_settings = await QuerySettings.extract_raw(ctx.author, self.bot, query) + embed = LookupView.embed(monster, query_settings).to_embed() + await ctx.send(embed=embed) + await self.log_id_result(ctx, monster.monster_id) + self.save_historic_data(query, monster) + + async def log_id_result(self, ctx, monster_id: int): + history = await self.config.user(ctx.author).id_history() + if monster_id in history: + history.remove(monster_id) + history.insert(0, monster_id) + if len(history) > HISTORY_DURATION: + history.pop() + await self.config.user(ctx.author).id_history.set(history) @commands.command() + @checks.bot_has_permissions(embed_links=True) + async def buttoninfo(self, ctx, *, query: str): + """Button farming theorycrafting info""" + dbcog = await self.get_dbcog() + monster = await dbcog.find_monster(query, ctx.author.id) + if monster is None: + await self.send_id_failure_message(ctx, query) + return + + original_author_id = ctx.message.author.id + info = button_info.get_info(dbcog, monster) + query_settings = await QuerySettings.extract_raw(ctx.author, self.bot, query) + display_options = ButtonInfoToggles() + alt_monsters = ButtonInfoViewState.get_alt_monsters_and_evos(dbcog, monster) + state = ButtonInfoViewState(original_author_id, ButtonInfoMenu.MENU_TYPE, query, display_options, + monster, alt_monsters, info, query_settings, + reaction_list=ButtonInfoMenuPanes.get_user_reaction_list(display_options)) + menu = ButtonInfoMenu.menu() + await menu.create(ctx, state) + + @commands.command(aliases=["ids"]) + @checks.bot_has_permissions(embed_links=True) + async def idsearch(self, ctx, *, query): + await self._do_idsearch(ctx, query) + + @commands.command() + @checks.bot_has_permissions(embed_links=True) + async def nadiffs(self, ctx, *, query): + await self._do_idsearch(ctx, query, child_menu_type=NaDiffMenu.MENU_TYPE, + child_reaction_list=NaDiffMenuPanes.emoji_names()) + + async def _do_idsearch(self, ctx, query, child_menu_type=None, + child_reaction_list=None): + dbcog = await self.get_dbcog() + query_settings = await QuerySettings.extract_raw(ctx.author, self.bot, query) + queried_props = await IdSearchViewState.do_query(dbcog, query, ctx.author.id, query_settings) + + if not queried_props or not queried_props.monster_list: + await ctx.send("No monster matched.") + return + + await self._do_monster_list(ctx, dbcog, query, queried_props, 'ID Search Results', + IdSearchViewState, + child_menu_type=child_menu_type, + child_reaction_list=child_reaction_list) + + @commands.command() + @checks.bot_has_permissions(embed_links=True) + async def allmats(self, ctx, *, query: str): + """Monster info (evo materials tab)""" + dbcog = await self.get_dbcog() + monster = await dbcog.find_monster(query, ctx.author.id) + + if not monster: + await self.send_id_failure_message(ctx, query) + return + monster_list = await AllMatsViewState.do_query(dbcog, monster) + if not monster_list: + await ctx.send(inline("This monster is not a mat for anything nor does it have a gem")) + return + + _, usedin, _, gemusedin, _, _, _, _ = await MaterialsViewState.do_query(dbcog, monster) + + title = 'Material For' if usedin else 'Gem is Material For' + await self._do_monster_list(ctx, dbcog, query, MonsterListQueriedProps(monster_list), title, AllMatsViewState) + + @commands.command() + @checks.bot_has_permissions(embed_links=True) async def evolist(self, ctx, *, query): - """Monster info (for all monsters in the evo tree)""" - m, err, debug_info = self.findMonster(query) - if m is not None: - await self._do_evolistmenu(ctx, m) - else: - await ctx.send(self.makeFailureMsg(err)) + dbcog = await self.get_dbcog() + monster = await dbcog.find_monster(query, ctx.author.id) - @commands.command(aliases=['leaders', 'leaderskills', 'ls']) - async def leaderskill(self, ctx, left_query: str, right_query: str = None, *, bad=None): + if monster is None: + await self.send_id_failure_message(ctx, query) + return + + monster_list, _ = await EvosViewState.do_query(dbcog, monster) + if monster_list is None: + await self.send_invalid_monster_message(ctx, query, monster, ', which has no alt evos') + return + await self._do_monster_list(ctx, dbcog, query, MonsterListQueriedProps(monster_list), + 'Evolution List', StaticMonsterListViewState) + + async def _do_monster_list(self, ctx, dbcog, query, queried_props: MonsterListQueriedProps, + title, view_state_type: Type[MonsterListViewState], + child_menu_type: Optional[str] = None, + child_reaction_list: Optional[List] = None + ): + raw_query = query + original_author_id = ctx.message.author.id + query_settings = await QuerySettings.extract_raw(ctx.author, self.bot, query) + initial_reaction_list = MonsterListMenuPanes.get_initial_reaction_list(len(queried_props.monster_list)) + instruction_message = 'Click a reaction to see monster details!' + + if child_menu_type is None: + child_menu_type = query_settings.child_menu_type.name + _, child_panes_class = padinfo_menu_map[child_menu_type] + child_reaction_list = child_panes_class.emoji_names() + + state = view_state_type(original_author_id, view_state_type.VIEW_STATE_TYPE, query, + queried_props, query_settings, + title, instruction_message, + child_menu_type=child_menu_type, + child_reaction_list=child_reaction_list, + reaction_list=initial_reaction_list + ) + parent_menu = MonsterListMenu.menu() + message = await ctx.send('Setting up!') + + ims = state.serialize() + user_config = await BotConfig.get_user(self.config, ctx.author.id) + data = { + 'dbcog': dbcog, + 'user_config': user_config, + } + child_state = SimpleTextViewState(original_author_id, view_state_type.VIEW_STATE_TYPE, + raw_query, query_settings, + instruction_message, + reaction_list=[] + ) + child_menu = SimpleTextMenu.menu() + child_message = await child_menu.create(ctx, child_state) + + data['child_message_id'] = child_message.id + try: + await parent_menu.transition(message, ims, MonsterListEmoji.refresh, ctx.author, **data) + await message.edit(content=None) + except discord.errors.NotFound: + # The user could delete the menu before we can do this + pass + + @commands.command() + @checks.bot_has_permissions(embed_links=True) + async def scroll(self, ctx, *, query: str): + dbcog = await self.get_dbcog() + monster = await dbcog.find_monster(query, ctx.author.id) + + if not monster: + await self.send_id_failure_message(ctx, query) + return + queried_props = await ScrollViewState.do_query(dbcog, monster) + title = 'Monster Book Scroll' + raw_query = query + original_author_id = ctx.message.author.id + query_settings = await QuerySettings.extract_raw(ctx.author, self.bot, query) + initial_reaction_list = ScrollMenuPanes.get_initial_reaction_list(len(queried_props.monster_list)) + instruction_message = 'Click a reaction to see monster details!' + + state = ScrollViewState(original_author_id, ScrollViewState.VIEW_STATE_TYPE, query, + queried_props, query_settings, + title, instruction_message, monster.monster_id, + child_menu_type=IdMenu.MENU_TYPE, + child_reaction_list=IdMenuPanes.emoji_names(), + reaction_list=initial_reaction_list + ) + parent_menu = MonsterListMenu.menu() + message = await ctx.send('Setting up!') + + ims = state.serialize() + user_config = await BotConfig.get_user(self.config, ctx.author.id) + data = { + 'dbcog': dbcog, + 'user_config': user_config, + } + + alt_monsters = IdViewState.get_alt_monsters_and_evos(dbcog, monster) + id_queried_props = await IdViewState.do_query(dbcog, monster) + full_reaction_list = IdMenuPanes.emoji_names() + is_jp_buffed = dbcog.database.graph.monster_is_discrepant(monster) + + child_state = IdViewState(original_author_id, IdMenu.MENU_TYPE, raw_query, query, monster, + alt_monsters, is_jp_buffed, query_settings, id_queried_props, + reaction_list=full_reaction_list) + child_menu = IdMenu.menu() + child_message = await child_menu.create(ctx, child_state) + + data['child_message_id'] = child_message.id + try: + await parent_menu.transition(message, ims, MonsterListEmoji.refresh, ctx.author, **data) + await message.edit(content=None) + except discord.errors.NotFound: + # The user could delete the menu before we can do this + pass + + @commands.command(aliases=['collabscroll', 'ss']) + @checks.bot_has_permissions(embed_links=True) + async def seriesscroll(self, ctx, *, query): + dbcog = await self.get_dbcog() + monster = await dbcog.find_monster(query, ctx.author.id) + + if monster is None: + await self.send_id_failure_message(ctx, query) + return + series_id = monster.series_id + series_object: "SeriesModel" = monster.series + title = series_object.name_en + paginated_monsters = None + query_settings = await QuerySettings.extract_raw(ctx.author, self.bot, query) + rarity = None + for rarity in SeriesScrollMenu.RARITY_INITIAL_TRY_ORDER: + paginated_monsters = await SeriesScrollViewState.do_query(dbcog, monster.series_id, + rarity, monster.server_priority) + if paginated_monsters: + break + all_rarities = SeriesScrollViewState.query_all_rarities(dbcog, series_id, monster.server_priority) + + raw_query = query + original_author_id = ctx.message.author.id + initial_reaction_list = SeriesScrollMenuPanes.get_initial_reaction_list(len(paginated_monsters)) + instruction_message = 'Click a reaction to see monster details!' + + state = SeriesScrollViewState(original_author_id, SeriesScrollMenu.MENU_TYPE, raw_query, query, + series_id, paginated_monsters, 0, int(rarity), + query_settings, + all_rarities, + title, instruction_message, + reaction_list=initial_reaction_list) + parent_menu = SeriesScrollMenu.menu() + message = await ctx.send('Setting up!') + + ims = state.serialize() + user_config = await BotConfig.get_user(self.config, ctx.author.id) + data = { + 'dbcog': dbcog, + 'user_config': user_config, + } + child_state = SimpleTextViewState(original_author_id, SeriesScrollMenu.MENU_TYPE, + raw_query, query_settings, + instruction_message, + reaction_list=[] + ) + child_menu = SimpleTextMenu.menu() + child_message = await child_menu.create(ctx, child_state) + + data['child_message_id'] = child_message.id + try: + await parent_menu.transition(message, ims, SeriesScrollEmoji.refresh, ctx.author, **data) + await message.edit(content=None) + except discord.errors.NotFound: + # The user could delete the menu before we can do this + pass + + @commands.command(aliases=['leaders', 'leaderskills', 'ls'], usage=" [card_2]") + @checks.bot_has_permissions(embed_links=True) + async def leaderskill(self, ctx, *, raw_query): """Display the multiplier and leaderskills for two monsters - If either your left or right query contains spaces, wrap in quotes. - e.g.: [p]leaderskill "r sonia" "b sonia" + Gets two monsters separated by a slash, wrapping quotes, a comma, + or spaces (if there's only two words). + [p]ls r sonia/ revo lu bu + [p]ls r sonia "revo lu bu" + [p]ls sonia lubu """ - if bad: - await ctx.send(inline('Too many inputs. Try wrapping your queries in quotes.')) + dbcog = await self.get_dbcog() + l_mon, l_query, r_mon, r_query = await leaderskill_query(dbcog, raw_query, ctx.author.id) + + err_msg = ('{} query failed to match a monster: [ {} ]. If your query is multiple words,' + ' try separating the queries with / or wrap with quotes.') + if l_mon is None: + await ctx.send(inline(err_msg.format('Left', l_query))) + return + if r_mon is None: + await ctx.send(inline(err_msg.format('Right', r_query))) return - # Handle a very specific failure case, user typing something like "uuvo ragdra" - if ' ' not in left_query and right_query is not None and ' ' not in right_query and bad is None: - combined_query = left_query + ' ' + right_query - nm, err, debug_info = self._findMonster(combined_query) - if nm and left_query in nm.prefixes: - left_query = combined_query - right_query = None - - left_m, left_err, _ = self.findMonster(left_query) - if right_query: - right_m, right_err, _ = self.findMonster(right_query) + l_query_settings = await QuerySettings.extract_raw(ctx.author, self.bot, l_query) + r_query_settings = await QuerySettings.extract_raw(ctx.author, self.bot, r_query or l_query) + + original_author_id = ctx.message.author.id + state = LeaderSkillViewState(original_author_id, LeaderSkillMenu.MENU_TYPE, raw_query, l_mon, r_mon, + l_query, r_query, l_query_settings, r_query_settings) + menu = LeaderSkillMenu.menu() + await menu.create(ctx, state) + + async def get_user_embed_color(self, ctx): + color = await self.config.user(ctx.author).color() + return self.user_color_to_discord_color(color) + + @staticmethod + def user_color_to_discord_color(color): + if color is None: + return Color.default() + elif color == "random": + return Color(random.randint(0x000000, 0xffffff)) else: - right_m, right_err, = left_m, left_err + return discord.Color(color) + + async def get_user_friends(self, original_author_id): + friend_cog = self.bot.get_cog("Friend") + return (await friend_cog.get_friends(original_author_id)) if friend_cog else [] + + @commands.command(aliases=['lssingle']) + @checks.bot_has_permissions(embed_links=True) + async def leaderskillsingle(self, ctx, *, query): + dbcog = await self.get_dbcog() + monster = await dbcog.find_monster(query, ctx.author.id) + if not monster: + await self.send_id_failure_message(ctx, query) + return + + query_settings = await QuerySettings.extract_raw(ctx.author, self.bot, query) + + original_author_id = ctx.message.author.id + state = LeaderSkillSingleViewState(original_author_id, LeaderSkillSingleMenu.MENU_TYPE, query, query_settings, + monster) + menu = LeaderSkillSingleMenu.menu() + await menu.create(ctx, state) - err_msg = '{} query failed to match a monster: [ {} ]. If your query is multiple words, wrap it in quotes.' - if left_err: - await ctx.send(inline(err_msg.format('Left', left_query))) + @commands.command(aliases=['tfinfo', 'xforminfo']) + @checks.bot_has_permissions(embed_links=True) + async def transforminfo(self, ctx, *, query): + """Show info about a transform card, including some helpful details about the base card.""" + dbcog = await self.get_dbcog() + base_mon, transformed_mon, monster_ids = await perform_transforminfo_query(dbcog, query, ctx.author.id) + + if not base_mon: + await self.send_id_failure_message(ctx, query) + return + + if not transformed_mon: + await self.send_invalid_monster_message(ctx, query, base_mon, ', which has no evos that transform') return - if right_err: - await ctx.send(inline(err_msg.format('Right', right_query))) + + original_author_id = ctx.message.author.id + tfinfo_queried_props = await TransformInfoViewState.do_query(dbcog, transformed_mon) + reaction_list = TransformInfoMenuPanes.get_user_reaction_list(len(monster_ids)) + is_jp_buffed = dbcog.database.graph.monster_is_discrepant(base_mon) + query_settings = await QuerySettings.extract_raw(ctx.author, self.bot, query) + + state = TransformInfoViewState(original_author_id, TransformInfoMenu.MENU_TYPE, query, + base_mon, transformed_mon, tfinfo_queried_props, monster_ids, + is_jp_buffed, + query_settings, + reaction_list=reaction_list) + menu = TransformInfoMenu.menu() + await menu.create(ctx, state) + + @commands.command(aliases=['awakehelp', 'awakeningshelp', 'awohelp', 'awokenhelp', 'awakeninghelp']) + @checks.bot_has_permissions(embed_links=True) + async def awakenings(self, ctx, *, query=None): + """Describe a monster's regular and super awakenings in detail. + + Leave blank to see a list of every awakening in the game.""" + dbcog = await self.get_dbcog() + query_settings = await QuerySettings.extract_raw(ctx.author, self.bot, query or '') + + # TODO: Fix this absolutely awful way of finding if the query is empty but has a QS + if not query or query.startswith('--'): + sort_type = AwakeningListSortTypes.numerical + paginated_skills = await AwakeningListViewState.do_query(dbcog, sort_type) + menu = AwakeningListMenu.menu() + state = AwakeningListViewState(ctx.message.author.id, AwakeningListMenu.MENU_TYPE, query_settings, + sort_type, paginated_skills, 0, dbcog.AWOKEN_SKILL_TOKEN_MAP, + reaction_list=AwakeningListMenuPanes.get_user_reaction_list(sort_type)) + await menu.create(ctx, state) + return + + monster = await dbcog.find_monster(query, ctx.author.id) + + if not monster: + await self.send_id_failure_message(ctx, query) return - emoji_to_embed = OrderedDict() - emoji_to_embed[self.ls_emoji] = monstersToLsEmbed(left_m, right_m) - emoji_to_embed[self.left_emoji] = monsterToEmbed(left_m, self.get_emojis()) - emoji_to_embed[self.right_emoji] = monsterToEmbed(right_m, self.get_emojis()) + original_author_id = ctx.message.author.id + menu = ClosableEmbedMenu.menu() + props = AwakeningHelpViewProps(monster=monster, token_map=dbcog.AWOKEN_SKILL_TOKEN_MAP) + state = ClosableEmbedViewState(original_author_id, ClosableEmbedMenu.MENU_TYPE, query, + query_settings, AwakeningHelpView.VIEW_TYPE, props) + await menu.create(ctx, state) - await self._do_menu(ctx, self.ls_emoji, EmojiUpdater(emoji_to_embed)) + @commands.command(aliases=['idhist']) + @checks.bot_has_permissions(embed_links=True) + async def idhistory(self, ctx): + """Show a list of the 11 most recent monsters that the user looked up.""" + dbcog = await self.get_dbcog() + history = await self.config.user(ctx.author).id_history() - @commands.command(aliases=['helppic', 'helpimg']) - async def helpid(self, ctx): - """Whispers you info on how to craft monster queries for [p]id""" - await ctx.author.send(box(HELP_MSG.format(ctx))) + monster_list = [dbcog.get_monster(m) for m in history] + + if not monster_list: + await ctx.send('Did not find any recent queries in history.') + return + queried_props = MonsterListQueriedProps(monster_list) + await self._do_monster_list(ctx, dbcog, '', queried_props, 'Result History', StaticMonsterListViewState) @commands.command() async def padsay(self, ctx, server, *, query: str = None): @@ -456,615 +930,682 @@ async def padsay(self, ctx, server, *, query: str = None): server = 'na' query = query.strip().lower() - m, err, debug_info = self.findMonster(query) - if m is not None: - voice_id = m.voice_id_jp if server == 'jp' else m.voice_id_na - base_dir = '/home/tactical0retreat/dadguide/data/media/voices' + dbcog = await self.get_dbcog() + monster = await dbcog.find_monster(query, ctx.author.id) + if monster is not None: + voice_id = monster.voice_id_jp if server == 'jp' else monster.voice_id_na + if voice_id is None: + await ctx.send(inline("No voice file found for " + monster.name_en)) + return + base_dir = settings.voiceDir() voice_file = os.path.join(base_dir, server, '{0:03d}.wav'.format(voice_id)) - header = '{} ({})'.format(monsterToHeader(m), server) + header = '{} ({})'.format(MonsterHeader.text_with_emoji(monster), server) if not os.path.exists(voice_file): await ctx.send(inline('Could not find voice for ' + header)) return await ctx.send('Speaking for ' + header) await speech_cog.play_path(channel, voice_file) else: - await ctx.send(self.makeFailureMsg(err)) - - @commands.command() - async def idmode(self, ctx, id_type): - """Switch between number mode and evo mode""" - if id_type in ['evo']: - if self.settings.setEvoID(ctx.author.id): - await ctx.send(inline("Done")) - else: - await ctx.send(inline("You're already using evo mode")) - elif id_type in ['number', 'default']: - if self.settings.rmEvoID(ctx.author.id): - await ctx.send(inline("Done")) - else: - await ctx.send(inline("You're already using number mode")) - else: - await ctx.send(inline("id_type must be 'number' or 'evo'")) - - @commands.group() - @checks.is_owner() - async def padinfo(self, ctx): - """PAD info management""" - - @padinfo.command() - @checks.is_owner() - async def setemojiservers(self, ctx, *, emoji_servers=''): - """Set the emoji servers by ID (csv)""" - self.settings.emojiServers().clear() - if emoji_servers: - self.settings.setEmojiServers(emoji_servers.split(',')) - await ctx.send(inline('Set {} servers'.format(len(self.settings.emojiServers())))) - - @checks.is_owner() - @padinfo.command() - async def iddiff(self, ctx): - """Runs the diff checker for id and id2""" - await ctx.send("Running diff checker...") - hist_aggreg = list(self.historic_lookups) + list(self.historic_lookups_id2) - s = 0 - f = [] - for query in hist_aggreg: - m1, err1, debug_info1 = self.findMonster(query) - m2, err2, debug_info2 = self.findMonster2(query) - if m1 == m2 or (m1 and m2 and m1.monster_id == m2.monster_id): - s += 1 - continue - - f.append((query, - [m1.monster_id if m1 else None, m2.monster_id if m2 else None], - [err1, err2], - [debug_info1, debug_info2] - )) - if m1 and m2: - ctx.send("Major Discrepency: {} -> {}/{}".format(query, m1.name_na, m2.name_na)) - await ctx.send("Done running diff checker. {}/{} passed.".format(s, len(hist_aggreg))) - print(f) - - def get_emojis(self): - server_ids = [int(sid) for sid in self.settings.emojiServers()] - return [e for g in self.bot.guilds if g.id in server_ids for e in g.emojis] - - def makeFailureMsg(self, err): - msg = ('Lookup failed: {}.\n' - 'Try one of , , [argbld]/[rgbld] . ' - 'Unexpected results? Use ^helpid for more info.').format(err) - return box(msg) - - def findMonster(self, query, na_only=False): - query = rmdiacritics(query) - nm, err, debug_info = self._findMonster(query, na_only) - - monster_no = nm.monster_id if nm else -1 - self.historic_lookups[query] = monster_no - json.dump(self.historic_lookups, open(self.historic_lookups_file_path, "w+")) - - m = self.get_monster_by_id(nm.monster_id) if nm else None - - return m, err, debug_info - - def _findMonster(self, query, na_only=False): - monster_index = self.index_na if na_only else self.index_all - return monster_index.find_monster(query) - - def findMonster2(self, query, na_only=False): - query = rmdiacritics(query) - nm, err, debug_info = self._findMonster2(query, na_only) - - monster_no = nm.monster_id if nm else -1 - self.historic_lookups_id2[query] = monster_no - json.dump(self.historic_lookups_id2, open(self.historic_lookups_file_path_id2, "w+")) - - m = self.get_monster_by_id(nm.monster_id) if nm else None - - return m, err, debug_info - - def _findMonster2(self, query, na_only=False): - monster_index = self.index_na if na_only else self.index_all - return monster_index.find_monster2(query) - - -class PadInfoSettings(CogSettings): - def make_default_settings(self): - config = { - 'animation_dir': '', - 'evo_id_users': [], + await self.send_id_failure_message(ctx, query) + + @commands.group(aliases=['idmode']) + async def idset(self, ctx): + """`[p]id` settings configuration""" + + @idset.command(name="list") + async def idset_list(self, ctx): + """`[p]id` settings list""" + fm_flags = await self.bot.get_cog("DBCog").config.user(ctx.author).fm_flags() + intro = f"Here are your current `{ctx.prefix}id` preference settings:\n" + user_settings = { + "cardlevel": '110' if 'cardlevel' not in fm_flags else CardLevelModifier(fm_flags['cardlevel']).name[2:], + "cardmode": 'Solo' if 'cardmode' not in fm_flags else CardModeModifier(fm_flags['cardmode']).name.title(), + "cardplus": '297' if 'cardplus' not in fm_flags else CardPlusModifier(fm_flags['cardplus']).name[4:], + "evogrouping": 'Grouped' if 'evogrouping' not in fm_flags or fm_flags['evogrouping'] == 1 else 'Split', + "evosort": 'Numerical' if 'evosort' not in fm_flags else AltEvoSort(fm_flags['evosort']).name.title(), + "linktarget": 'PADIndex' if 'linktarget' not in fm_flags or fm_flags[ + 'linktarget'] == 0 else MonsterLinkTarget(fm_flags['linktarget']).name.title(), + "lsmultiplier": 'Double' if 'lsmultiplier' not in fm_flags else LsMultiplier(fm_flags['lsmultiplier']).name[ + 2:].title(), + "naprio": 'On' if 'na_prio' not in fm_flags or fm_flags['na_prio'] == 1 else 'Off', + "ormod prio": 'On' if 'ormod_prio' not in fm_flags or fm_flags['ormod_prio'] is True else 'Off', + "server": 'Default' if 'server' not in fm_flags or fm_flags['server'] == 'COMBINED' else fm_flags['server'], + "(Donor Only) embedcolor": 'Default' if 'embedcolor' not in fm_flags else fm_flags['embedcolor'].title(), } - return config - - def emojiServers(self): - key = 'emoji_servers' - if key not in self.bot_settings: - self.bot_settings[key] = [] - return self.bot_settings[key] - - def setEmojiServers(self, emoji_servers): - es = self.emojiServers() - es.clear() - es.extend(emoji_servers) - self.save_settings() - - def setEvoID(self, user_id): - if user_id not in self.bot_settings['evo_id_users']: - self.bot_settings['evo_id_users'].append(user_id) - self.save_settings() - return True - return False - - def rmEvoID(self, user_id): - if user_id in self.bot_settings['evo_id_users']: - self.bot_settings['evo_id_users'].remove(user_id) - self.save_settings() - return True - return False - - def checkEvoID(self, user_id): - return user_id in self.bot_settings['evo_id_users'] - - -def monsterToHeader(m: "DgMonster", link=False): - msg = 'No. {} {}'.format(m.monster_no_na, m.name_na) - return '[{}]({})'.format(msg, get_pdx_url(m)) if link else msg + await ctx.send(intro + '\n'.join(["\t{}: {}".format(k, v) for k, v in user_settings.items()])) + @is_donor() + @idset.command() + async def embedcolor(self, ctx, *, color: converters.EmbedColor): + """(DONOR ONLY) The color of all your `[p]id` embeds! -def monsterToJpSuffix(m: "DgMonster"): - suffix = "" - if m.roma_subname: - suffix += ' [{}]'.format(m.roma_subname) - if not m.on_na: - suffix += ' (JP only)' - return suffix + Examples: + [p]idset embedcolor green + [p]idset embedcolor #a10000 + [p]idset embedcolor random + Picking random will choose a random hex code every time you use [p]id! + """ -def monsterToLongHeader(m: "DgMonster", link=False): - msg = monsterToHeader(m) + monsterToJpSuffix(m) - return '[{}]({})'.format(msg, get_pdx_url(m)) if link else msg - - -def monsterToEvoText(m: "DgMonster"): - output = monsterToLongHeader(m) - for ae in sorted(m.alt_evos, key=lambda x: int(x.monster_id)): - output += "\n\t- {}".format(monsterToLongHeader(ae)) - return output - - -def monsterToThumbnailUrl(m: "DgMonster"): - return get_portrait_url(m) - - -def monsterToBaseEmbed(m: "DgMonster"): - header = monsterToLongHeader(m) - embed = discord.Embed() - embed.set_thumbnail(url=monsterToThumbnailUrl(m)) - embed.title = header - embed.url = get_pdx_url(m) - embed.set_footer(text='Requester may click the reactions below to switch tabs') - return embed - - -def printEvoListFields(list_of_monsters, embed, name): - if not len(list_of_monsters): - return - field_name = name.format(len(list_of_monsters)) - field_data = '' - for ae in sorted(list_of_monsters, key=lambda x: int(x.monster_id)): - field_data += "{}\n".format(monsterToLongHeader(ae, link=True)) - - embed.add_field(name=field_name, value=field_data) - - -def monsterToEvoEmbed(m: "DgMonster"): - embed = monsterToBaseEmbed(m) - - if not len(m.alt_evos) and not m.evo_gem: - embed.description = 'No alternate evos or evo gem' - return embed + async with self.bot.get_cog("DBCog").config.user(ctx.author).fm_flags() as fm_flags: + fm_flags['embedcolor'] = color + await ctx.tick() + + @idset.command(usage="") + async def naprio(self, ctx, value: bool): + """Whether `[p]id` will default away from new evos of monsters that aren't in NA yet""" + async with self.bot.get_cog("DBCog").config.user(ctx.author).fm_flags() as fm_flags: + # The current na_prio enum has 0 and 1 instead of False and True as its values + fm_flags['na_prio'] = int(value) + await send_confirmation_message( + ctx, f"NA monster prioritization has been **{'en' if value else 'dis'}abled**.") + + @idset.command() + async def ormodprio(self, ctx, value: bool): + """Whether `[p]id` will be order-sensitive with respect to your "or" clauses""" + async with self.bot.get_cog("DBCog").config.user(ctx.author).fm_flags() as fm_flags: + fm_flags['ormod_prio'] = bool(value) + await send_confirmation_message( + ctx, f"Order in or clause prioritization has been **{'en' if value else 'dis'}abled**.") + + @idset.command() + async def server(self, ctx, server: str): + """The server used for your `[p]id` queries + + `[p]idset server default`: Include both NA & JP cards. You can still use `--na` as needed. + `[p]idset server na`: Include only NA cards. You can still use `--allservers` as needed. + """ + dbcog = await self.get_dbcog() + async with self.bot.get_cog("DBCog").config.user(ctx.author).fm_flags() as fm_flags: + if server.upper() == "DEFAULT": + fm_flags['server'] = dbcog.DEFAULT_SERVER.value + elif server.upper() in [s.value for s in dbcog.SERVERS]: + fm_flags['server'] = server.upper() + else: + if dbcog.DEFAULT_SERVER == Server.COMBINED: + await send_cancellation_message(ctx, "Server must be `default` or `na`") + else: + await send_cancellation_message(ctx, "Server must be `na` or `combined`") + return + await ctx.tick() - printEvoListFields(m.alt_evos, embed, '{} alternate evo(s)') - if not m.evo_gem: - return embed - printEvoListFields([m.evo_gem], embed, '{} evo gem(s)') + @idset.command() + async def evosort(self, ctx, value: str): + """The order for scrolling alt evos in your `[p]id` queries - return embed + `[p]idset evosort numerical`: Show alt evos sorted by card ID number. + `[p]idset evosort dfs`: Show alt evos in a depth-first-sort order, starting with the base of the tree. + """ + await self._do_idsetting(ctx, 'evosort', AltEvoSort, value, + 'dfs', 'dfs', + 'numerical', 'numerical', ) + @idset.command() + async def lsmultiplier(self, ctx, value: str): + """The display of LS multipliers in your `[p]id` queries -def printMonsterEvoOfList(monster_list, embed, field_name): - if not len(monster_list): - return - field_data = '' - if len(monster_list) > 5: - field_data = '{} monsters'.format(len(monster_list)) - else: - item_count = min(len(monster_list), 5) - for ae in sorted(monster_list, key=lambda x: x.monster_no_na, reverse=True)[:item_count]: - field_data += "{}\n".format(monsterToLongHeader(ae, link=True)) - embed.add_field(name=field_name, value=field_data) + `[p]idset lsmultiplier double`: [Default] Show leader skill multipliers assuming the card is paired with itself. + `[p]idset lsmultiplier single`: Show leader skill multipliers of just the single card. + """ + await self._do_idsetting(ctx, 'lsmultiplier', LsMultiplier, value, + 'double', 'lsdouble', + 'single', 'lssingle', ) + @idset.command() + async def cardplus(self, ctx, value: str): + """The monster stat plus points amount in your `[p]id` queries -def monsterToEvoMatsEmbed(m: "DgMonster"): - embed = monsterToBaseEmbed(m) + `[p]idset cardplus 297`: [Default] Show cards with +297 stats. + `[p]idset cardplus 0`: Show cards with +0 stats. + """ + await self._do_idsetting(ctx, 'cardplus', CardPlusModifier, value, + '297', 'plus297', + '0', 'plus0', ) - mats_for_evo = m.mats_for_evo + @idset.command() + async def cardmode(self, ctx, value: str): + """Switch between solo and coop - field_name = 'Evo materials' - field_data = '' - if len(mats_for_evo) > 0: - for ae in m.mats_for_evo: - field_data += "{}\n".format(monsterToLongHeader(ae, link=True)) - else: - field_data = 'None' - embed.add_field(name=field_name, value=field_data) + `[p]idset mode solo`: [Default] Show cards with stats in solo + `[p]idset mode coop`: Show cards with stats in coop (i.e. multiboost) + """ + await self._do_idsetting(ctx, 'cardmode', CardModeModifier, value, + 'solo', 'solo', + 'coop', 'coop', ) - printMonsterEvoOfList(m.material_of, embed, 'Material for') - if not m.evo_gem: - return embed - printMonsterEvoOfList(m.evo_gem.material_of, embed, "Tree's gem (may not be this evo) is mat for") - return embed + @idset.command() + async def cardlevel(self, ctx, value: str): + """The limitbreak level in your `[p]id` queries + `[p]idset cardlevel 110`: [Default] Show LB stats at 110. + `[p]idset cardlevel 120`: Show LB stats at 120. + """ + await self._do_idsetting(ctx, 'cardlevel', CardLevelModifier, value, + '120', 'lv120', + '110', 'lv110', ) -def monsterToPantheonEmbed(m: "DgMonster"): - full_pantheon = m.series.monsters - pantheon_list = list(filter(lambda x: x.evo_from is None, full_pantheon)) - if len(pantheon_list) == 0 or len(pantheon_list) > 6: - return None + @idset.command() + async def evogrouping(self, ctx, value: str): + """If trees are combined in your `[p]ids` queries - embed = monsterToBaseEmbed(m) + `[p]idset evogrouping grouped`: [Default] Show trees grouped. + `[p]idset evogrouping split`: Show individual evos of trees split apart. + """ + await self._do_idsetting(ctx, 'evogrouping', EvoGrouping, value, + 'split', 'splitevos', + 'grouped', 'groupevos') - field_name = 'Pantheon: ' + m.series.name - field_data = '' - for monster in sorted(pantheon_list, key=lambda x: x.monster_no_na): - field_data += '\n' + monsterToHeader(monster, link=True) - embed.add_field(name=field_name, value=field_data) + @idset.command() + async def linktarget(self, ctx, value: str): + """Monster link targets in your `[p]ids` queries - return embed + `[p]idset linktarget padindex`: [Default] Link to PADIndex always. + `[p]idset linktarget ilmina`: Link to Ilmina for cards that are in NA. + """ + await self._do_idsetting(ctx, 'linktarget', MonsterLinkTarget, value, + 'padindex', 'padindex', + 'ilmina', 'ilmina') + @idset.command() + async def skilldisplay(self, ctx, value: str): + """Monster skill display in your `[p]ids` queries -def monsterToSkillupsEmbed(m: "DgMonster"): - skillups_list = m.active_skill.skillups if m.active_skill else [] - if len(skillups_list) == 0: - return None + `[p]idset skilldisplay skilltexts`: [Default] Show skill descriptions. + `[p]idset skilldisplay skillnames`: Show the names of the skills. + """ + await self._do_idsetting(ctx, 'skilldisplay', SkillDisplay, value, + 'skilltexts', 'skilltexts', + 'skillnames', 'skillnames') + + async def _do_idsetting(self, ctx, setting_name, enum_type: EnumMeta, value, + value1, value1_flag, + value2, value2_flag): + async with self.bot.get_cog("DBCog").config.user(ctx.author).fm_flags() as fm_flags: + value = value.lower() + if value == value1: + fm_flags[setting_name] = enum_type[value1_flag].value + not_value = value2 + not_value_flag = value2_flag + elif value == value2: + fm_flags[setting_name] = enum_type[value2_flag].value + not_value = value1 + not_value_flag = value1_flag + else: + await send_cancellation_message( + ctx, + f'Please input an allowed value, either `{value1}` or `{value2}`.') + return + await send_confirmation_message( + ctx, + f"Your default `{ctx.prefix}id` {setting_name} preference has been set to **{value}**. You can temporarily access `{not_value}` with the flag `--{not_value_flag}` in your queries.") - embed = monsterToBaseEmbed(m) + @commands.group() + @checks.is_owner() + async def padinfo(self, ctx): + """PAD info management""" - field_name = 'Skillups' - field_data = '' + @padinfo.group() + @checks.is_owner() + async def emojiservers(self, ctx): + """Emoji server subcommand""" - # Prevent huge skillup lists - if len(skillups_list) > 8: - field_data = '({} skillups omitted)'.format(len(skillups_list) - 8) - skillups_list = skillups_list[0:8] + @emojiservers.command(name="add") + @checks.is_owner() + async def es_add(self, ctx, server_id: int): + """Add the emoji server by ID""" + emoji_servers = settings.emojiServers() + if server_id not in emoji_servers: + emoji_servers.append(server_id) + settings.save_settings() + await ctx.tick() + + @emojiservers.command(name="remove", aliases=['rm', 'del']) + @checks.is_owner() + async def es_rm(self, ctx, server_id: int): + """Remove the emoji server by ID""" + emoji_servers = settings.emojiServers() + if server_id not in emoji_servers: + await ctx.send("That emoji server is not set.") + return + emoji_servers.remove(server_id) + settings.save_settings() + await ctx.tick() - for monster in sorted(skillups_list, key=lambda x: x.monster_no_na): - field_data += '\n' + monsterToHeader(monster, link=True) + @emojiservers.command(name="list", aliases=['show']) + @checks.is_owner() + async def es_show(self, ctx): + """List the emoji servers by ID""" + emoji_servers = settings.emojiServers() + await ctx.send(box("\n".join(str(s) for s in emoji_servers))) - if len(field_data.strip()): - embed.add_field(name=field_name, value=field_data) + @padinfo.command() + @checks.is_owner() + async def setvoicepath(self, ctx, *, path=''): + """Set path to the voice direcory""" + settings.setVoiceDir(path) + await ctx.tick() - return embed + def get_emojis(self): + server_ids = [int(sid) for sid in settings.emojiServers()] + return [e for g in self.bot.guilds if g.id in server_ids for e in g.emojis] + @staticmethod + async def send_id_failure_message(ctx, query: str): + await ctx.send("Sorry, your query {0} didn't match any results :(\n" + "See <{2}> for " + "documentation on `{1.prefix}id`! You can also run `{1.prefix}idhelp ` to get " + "help with querying a specific monster.".format(inline(query), ctx, IDGUIDE)) + + @commands.command(aliases=["iddebug", "dbid", "iddb"]) + async def debugid(self, ctx, server: Optional[Server] = Server.COMBINED, *, query): + """Get helpful id information about a monster""" + dbcog = await self.get_dbcog() + index = await dbcog.get_index(server) + mon = await dbcog.find_monster(query, ctx.author.id) + if mon is None: + await ctx.send(box("Your query didn't match any monsters.")) + return + base_monster = dbcog.database.graph.get_base_monster(mon) + mods = index.modifiers[mon] + manual_modifiers = index.manual_modifiers[mon.monster_id] + EVOANDTYPE = dbcog.token_maps.EVO_TOKENS.union(dbcog.token_maps.TYPE_TOKENS) + + def mod_token_str(f: Callable[[str], bool]) -> str: + return ' '.join(sorted(t for t in mods if f(t) and not t.startswith('_'))) + + ret = (f"[{mon.monster_id}] {mon.name_en}\n" + f"Base: [{base_monster.monster_id}] {base_monster.name_en}\n" + f"Series: {mon.series.name_en} ({mon.series_id}, {mon.series.series_type})\n\n" + f"[Name Tokens] {' '.join(sorted(t for t, ms in index.name_tokens.items() if mon in ms))}\n" + f"[Fluff Tokens] {' '.join(sorted(t for t, ms in index.fluff_tokens.items() if mon in ms))}\n\n" + f"[Manual Tokens]\n" + f" Treenames: {' '.join(sorted(t for t, ms in index.manual_treenames.items() if mon in ms))}\n" + f" Nicknames: {' '.join(sorted(t for t, ms in index.manual_cardnames.items() if mon in ms))}\n\n" + f"[Modifier Tokens]\n" + f" Attribute: {mod_token_str(lambda t: t.split('-')[0] in dbcog.token_maps.COLOR_TOKENS)}\n" + f" Awakening: {mod_token_str(lambda t: t.split('-')[0] in dbcog.token_maps.AWAKENING_TOKENS)}\n" + f" Evo & Type: {mod_token_str(lambda t: t.split('-')[0] in EVOANDTYPE)}\n" + f" Other: {mod_token_str(lambda t: t.split('-')[0] not in dbcog.token_maps.OTHER_HIDDEN_TOKENS)}\n" + f"Manually Added: {' '.join(sorted(manual_modifiers))}\n") + for page in pagify(ret): + await ctx.send(box(page)) -def monsterToPicUrl(m: "DgMonster"): - return get_pic_url(m) + @commands.command() + async def debugiddist(self, ctx, s1, s2): + """Find the distance between two queries. + For name tokens, the full word goes second as name token matching is not commutitive. Inputs will be converted to lowercase. + """ + dbcog = await self.get_dbcog() + s1 = s1.lower() + s2 = s2.lower() + dist = dbcog.mon_finder.calc_ratio_modifier(s1, s2) + dist2 = dbcog.mon_finder.calc_ratio_name(s1, s2) + yes = '\N{WHITE HEAVY CHECK MARK}' + no = '\N{CROSS MARK}' + await ctx.send(f"Printing info for {inline(s1)}, {inline(s2)}\n" + + box(f"Jaro-Winkler Distance: {round(dist, 4)}\n" + f"Name Matching Distance: {round(dist2, 4)}\n" + f"Modifier token threshold: {dbcog.mon_finder.MODIFIER_JW_DISTANCE} " + f"{yes if dist >= dbcog.mon_finder.MODIFIER_JW_DISTANCE else no}\n" + f"Name token threshold: {dbcog.mon_finder.TOKEN_JW_DISTANCE} " + f"{yes if dist2 >= dbcog.mon_finder.TOKEN_JW_DISTANCE else no}")) + + @commands.command(aliases=['helpid']) + async def idhelp(self, ctx, *, query=""): + """Get help with an id query""" + await ctx.send( + "See <{0}> for documentation on `{1.prefix}id`!" + " Use `{1.prefix}idmeaning` to query the meaning of any modifier token." + " Remember that other than `equip`, modifiers must be at the start of the query." + "".format(IDGUIDE, ctx)) + if query: + await self.debugid(ctx, query=query) + + @commands.command(aliases=["idcheckmod", "lookupmod", "idlookupmod", "luid", "idlu"]) + async def idmeaning(self, ctx, token, server: Optional[Server] = Server.COMBINED): + """Get all the meanings of a token (bold signifies base of a tree)""" + token = token.replace(" ", "") + DGCOG = await self.get_dbcog() + index = await DGCOG.get_index(server) + + tms = DGCOG.token_maps + awokengroup = "(" + "|".join(re.escape(aw) for aws in tms.AWOKEN_SKILL_MAP.values() for aw in aws) + ")" + awakenings = {a.awoken_skill_id: a for a in DGCOG.database.get_all_awoken_skills()} + series = {s.series_id: s for s in DGCOG.database.get_all_series()} + + ret = "" + + def write_name_token(token_dict, token_type, is_multiword=False): + def f(m, s): + return bold(s) if DGCOG.database.graph.monster_is_base(m) else s + + token_ret = "" + so = [] + for mon in sorted(token_dict[token], key=lambda m: m.monster_id): + if (mon in index.mwtoken_creators[token]) == is_multiword: + so.append(mon) + if len(so) > 5: + token_ret += f"\n\n{token_type}\n" + ", ".join(f(m, str(m.monster_id)) for m in so[:10]) + token_ret += f"... ({len(so)} total)" if len(so) > 10 else "" + elif so: + token_ret += f"\n\n{token_type}\n" + "\n".join( + f(m, f"{str(m.monster_id).rjust(4)}. {m.name_en}") for m in so) + return token_ret + + ret += write_name_token(index.manual, "\N{LARGE PURPLE CIRCLE} [Multi-Word Tokens]", True) + ret += write_name_token(index.manual, "[Manual Tokens]") + ret += write_name_token(index.name_tokens, "[Name Tokens]") + ret += write_name_token(index.fluff_tokens, "[Fluff Tokens]") + + submwtokens = [t for t in index.multi_word_tokens if token in t] + if submwtokens: + ret += "\n\n[Multi-word Super-tokens]\n" + for t in submwtokens: + if not index.all_name_tokens[''.join(t)]: + continue + creators = sorted(index.mwtoken_creators["".join(t)], key=lambda m: m.monster_id) + ret += f"{' '.join(t).title()}" + ret += f" ({', '.join(f'{m.monster_id}' for m in creators)})" if creators else '' + ret += (" ( \u2014> " + + str(DGCOG.mon_finder.get_most_eligable_monster( + index.all_name_tokens[''.join(t)]).monster_id) + + ")\n") + + def additmods(ms, om): + if len(ms) == 1: + return "" + return "\n\tAlternate names: " + ', '.join(inline(m) for m in ms if m != om) + + meanings = '\n'.join([ + *["Evo: " + k.value + additmods(v, token) + for k, v in tms.EVO_MAP.items() if token in v], + *["Type: " + get_type_emoji(k) + ' ' + k.name + additmods(v, token) + for k, v in tms.TYPE_MAP.items() if token in v], + *["Misc: " + k.value + additmods(v, token) + for k, v in tms.MISC_MAP.items() if token in v], + *["Awakening: " + get_awakening_emoji(k) + ' ' + awakenings[k.value].name_en + additmods(v, token) + for k, v in tms.AWOKEN_SKILL_MAP.items() if token in v], + *["Main attr: " + get_attribute_emoji_by_enum(k, None) + ' ' + k.name.replace("Nil", "None") + + additmods(v, token) + for k, v in tms.COLOR_MAP.items() if token in v], + *["Sub attr: " + get_attribute_emoji_by_enum(False, k) + ' ' + k.name.replace("Nil", "None") + + additmods(v, token) + for k, v in tms.SUB_COLOR_MAP.items() if token in v], + *["Dual attr: " + get_attribute_emoji_by_enum(k[0], k[1]) + ' ' + k[0].name.replace("Nil", "None") + + '/' + k[1].name.replace("Nil", "None") + additmods(v, token) + for k, v in tms.DUAL_COLOR_MAP.items() if token in v], + *["Series: " + series[k].name_en + additmods(v, token) + for k, v in index.series_id_to_pantheon_nickname.items() if token in v], + + *["Rarity: " + m for m in re.findall(r"^(\d+)\*$", token)], + *["Base rarity: " + m for m in re.findall(r"^(\d+)\*b$", token)], + *[f"[UNSUPPORTED] Multiple awakenings: {m}x {awakenings[a.value].name_en}" + f"{additmods([f'{m}*{d}' for d in v], token)}" + for m, ag in re.findall(r"^(\d+)\*{}$".format(awokengroup), token) + for a, v in tms.AWOKEN_SKILL_MAP.items() if ag in v] + ]) + + if meanings or ret: + for page in pagify(meanings + "\n\n" + ret.strip()): + await ctx.send(page) + else: + await ctx.send(f"There are no modifiers that match `{token}`.") + + @commands.command(aliases=["tracebackid", "tbid", "idtb"]) + async def idtraceback(self, ctx, *, query): + """Get the traceback of an id query""" + selected_monster_id = None + if "/" in query: + query, selected_monster_id = query.split("/", 1) + if not selected_monster_id.strip().isdigit(): + await ctx.send("Monster id must be an int.") + return + selected_monster_id = int(selected_monster_id.strip()) -def monsterToPicEmbed(m: "DgMonster", animated=False): - embed = monsterToBaseEmbed(m) - url = monsterToPicUrl(m) - embed.set_image(url=url) - # Clear the thumbnail, don't need it on pic - embed.set_thumbnail(url='') - extra_links = [] - if animated: - extra_links.append('Animation: {} -- {}'.format(monsterToVideoUrl(m), monsterToGifUrl(m))) - if m.orb_skin_id is not None: - extra_links.append('Orb Skin: {} -- {}'.format(monsterToOrbSkinUrl(m), monsterToOrbSkinCBUrl(m))) - if len(extra_links) > 0: - embed.add_field(name='Extra Links', value='\n'.join(extra_links)) + dbcog = await self.get_dbcog() - return embed + m_info, e_info = await dbcog.find_monster_debug(query) + if m_info.matched_monster is None: + await ctx.send("No monster matched.") + return -def monsterToVideoUrl(m: "DgMonster", link_text='(MP4)'): - return '[{}]({})'.format(link_text, VIDEO_TEMPLATE.format(m.monster_no_jp)) + if selected_monster_id is not None: + selected = {m for m in m_info.valid_monsters if m.monster_id == selected_monster_id} + if not selected: + await ctx.send("The requested monster was not found as a result of the query.") + return + monster = selected.pop() + else: + monster = m_info.matched_monster + + score = m_info.monster_matches[monster].score + ntokens = m_info.monster_matches[monster].name + mtokens = m_info.monster_matches[monster].mod + lower_prio = {m for m in m_info.monster_matches + if m_info.monster_matches[m].score == m_info.monster_matches[monster].score + }.difference({monster}) + if len(lower_prio) > 20: + lpstr = f"{len(lower_prio)} other monsters." + else: + lpstr = "\n".join(f"{MonsterHeader.text_with_emoji(m)}" for m in lower_prio) + + mtokenstr = '\n'.join((f"{inline(t[0])}{(': ' + t[1]) if t[0] != t[1] and t[1] else ''}" + + ('' if not t[1] else " (exact)" if t[0] == t[1] + else f" ({round(dbcog.mon_finder.calc_ratio_modifier(t[0], t[1]), 2)})") + + f" {t[2]}").strip() + for t in sorted(mtokens)) + ntokenstr = '\n'.join(f"{inline(t[0])}{(': ' + t[1]) if t[0] != t[1] else ''}" + f" ({round(dbcog.mon_finder.calc_ratio_name(t[0], t[1]), 2) if t[0] != t[1] else 'exact'})" + f" {t[2]}".strip() + for t in sorted(ntokens)) + + original_author_id = ctx.message.author.id + menu = ClosableEmbedMenu.menu() + query_settings = await QuerySettings.extract_raw(ctx.author, self.bot, query) + props = IdTracebackViewProps(monster=monster, score=score, name_tokens=ntokenstr, modifier_tokens=mtokenstr, + lower_priority_monsters=lpstr if lower_prio else "None") + state = ClosableEmbedViewState(original_author_id, ClosableEmbedMenu.MENU_TYPE, query, + query_settings, IdTracebackView.VIEW_TYPE, props) + await menu.create(ctx, state) + @commands.command() + @checks.bot_has_permissions(embed_links=True) + async def expcurve(self, ctx, start, end: Optional[int], *, query=''): + if start.isdigit() and end is None and not query: + start, end, query = '', None, start + + if start.isdigit() and end is not None: + start, offset = int(start), 0 + elif (match := re.fullmatch(r'(\d+)\[(\d*\.?\d*)]', start)) and end is not None: + start, offset = int(match.group(1)), float(match.group(2) or '0') + elif (end is None and not start.isdigit()) or (query and not start and not end): + start, offset, query = 1, 0, start + ' ' + query + else: + return await ctx.send("Invalid syntax for argument `start`.") -def monsterToGifUrl(m: "DgMonster", link_text='(GIF)'): - return '[{}]({})'.format(link_text, GIF_TEMPLATE.format(m.monster_no_jp)) + if (monster := await self._get_monster(ctx, query)) is None: + return + if end is None: + end = monster.level + + if monster.exp_to_level(start + 1) - monster.exp_to_level(start) < offset and start <= 99 \ + or offset > 5e6 and start <= 110 or offset > 20e6: + return await send_cancellation_message(ctx, "Offset too large.") + if start <= 0 or end > 120 or end < start or (start == end and offset): + return await send_cancellation_message(ctx, f"Invalid bounds ({start}[{offset}] - {end}).") + + header = MonsterHeader.menu_title(monster, use_emoji=True).to_markdown() + if not monster.limit_mult and end > 99: + return await send_cancellation_message(ctx, f"{header} cannot limit break.") + if monster.level < end <= 99: + return await send_cancellation_message(ctx, f"{header} cannot get to level {end} " + f"(max level {monster.level}).") + + original_author_id = ctx.message.author.id + menu = ClosableEmbedMenu.menu() + query_settings = await QuerySettings.extract_raw(ctx.author, self.bot, query) + props = ExperienceCurveViewProps(monster=monster, low=start, high=end, offset=offset) + state = ClosableEmbedViewState(original_author_id, ClosableEmbedMenu.MENU_TYPE, query, + query_settings, ExperienceCurveView.VIEW_TYPE, props) + await menu.create(ctx, state) -def monsterToOrbSkinUrl(m: "DgMonster", link_text='Regular'): - return '[{}]({})'.format(link_text, ORB_SKIN_TEMPLATE.format(m.orb_skin_id)) + @commands.command() + async def boardlink(self, ctx, *, message): + """Generate a Dawnglare link from the user provided string. + Default fill direction is right then down. + Use -1 to invert fill, going down then right.""" + board = BoardGenerator(message.upper()) -def monsterToOrbSkinCBUrl(m: "DgMonster", link_text='Color Blind'): - return '[{}]({})'.format(link_text, ORB_SKIN_CB_TEMPLATE.format(m.orb_skin_id)) + if board.invalid_size: + await send_cancellation_message(ctx, "An invalid board was defined. Please enter a 5x4, 6x5, or 7x6 board.") + if board.invalid_orbs: + await send_cancellation_message(ctx, + f"An invalid letter was used. Only {board.allowed_letters} are allowed.") -def monsterToGifEmbed(m: "DgMonster"): - embed = monsterToBaseEmbed(m) - url = monsterToGifUrl(m) - embed.set_image(url=url) - # Clear the thumbnail, don't need it on pic - embed.set_thumbnail(url='') - return embed + if not (board.invalid_size or board.invalid_orbs): + await ctx.send(board.link) + @commands.command() + async def droploc(self, ctx, *, query): + dbcog = await self.get_dbcog() -def monstersToLsEmbed(left_m: "DgMonster", right_m: "DgMonster"): - lls = left_m.leader_skill - rls = right_m.leader_skill + monster = await dbcog.find_monster(query, ctx.author.id) + if monster is None: + return await ctx.send("No monster found.") - if lls: lhp, latk, lrcv, lresist = lls.data - else: lhp, latk, lrcv, lresist = 1, 1, 1, 0 + subdgs = dbcog.database.dungeon.get_subdungeons_from_drop_monster(monster) + if not subdgs: + return await ctx.send("This monster does not drop in any dungeons.") - if rls: rhp, ratk, rrcv, rresist = rls.data - else: rhp, ratk, rrcv, rresist = 1, 1, 1, 0 - - multiplier_text = createMultiplierText(lhp, latk, lrcv, lresist, rhp, ratk, rrcv, rresist) + dgs = dbcog.database.dungeon.get_dungeon_mapping(subdgs) - embed = discord.Embed() - embed.title = 'Multiplier [{}]\n\n'.format(multiplier_text) - description = '' - description += '\n**{}**\n{}'.format( - monsterToHeader(left_m, link=True), - left_m.leader_skill.desc if left_m.leader_skill else 'None') - description += '\n**{}**\n{}'.format( - monsterToHeader(right_m, link=True), - right_m.leader_skill.desc if right_m.leader_skill else 'None') - embed.description = description + msg = "" + for dg, sds in dgs.items(): + msg += "\n" + dg.name_en + for sd in sds: + msg += "\n " + sd.name_en + await ctx.send(msg[1:]) - return embed + @commands.command() + async def skyo(self, ctx, *, search_text): + """Show the subdungeon ids of all matching dungeons""" + dbcog = await self.get_dbcog() + db: "DBCogDatabase" = dbcog.database.database + qs = await QuerySettings.extract_raw(ctx.author, self.bot, search_text) -def monsterToHeaderEmbed(m: "DgMonster"): - header = monsterToLongHeader(m, link=True) - embed = discord.Embed() - embed.description = header - return embed + sds = await self.get_subdungeons(search_text, db) + if not sds: + return await ctx.send(f"No dungeons found") -def monsterToTypeString(m: "DgMonster"): - return '/'.join([t.name for t in m.types]) + dungeons = self.make_dungeon_dict(sds) + menu = ClosableEmbedMenu.menu() + props = SkyoLinksViewProps(sorted(dungeons.values(), key=lambda d: d['idx'])) + state = ClosableEmbedViewState(ctx.message.author.id, ClosableEmbedMenu.MENU_TYPE, search_text, + qs, SkyoLinksView.VIEW_TYPE, props) + return await menu.create(ctx, state) -def monsterToAcquireString(m: "DgMonster"): - acquire_text = None - if m.farmable and not m.mp_evo: - # Some MP shop monsters 'drop' in PADR - acquire_text = 'Farmable' - elif m.farmable_evo and not m.mp_evo: - acquire_text = 'Farmable Evo' - elif m.in_pem: - acquire_text = 'In PEM' - elif m.pem_evo: - acquire_text = 'PEM Evo' - elif m.in_rem: - acquire_text = 'In REM' - elif m.rem_evo: - acquire_text = 'REM Evo' - elif m.in_mpshop: - acquire_text = 'MP Shop' - elif m.mp_evo: - acquire_text = 'MP Shop Evo' - return acquire_text - - -def match_emoji(emoji_list, name): - for e in emoji_list: - if e.name == name: - return e - return name - - -def monsterToEmbed(m: "DgMonster", emoji_list): - embed = monsterToBaseEmbed(m) - - info_row_1 = monsterToTypeString(m) - acquire_text = monsterToAcquireString(m) - tet_text = m.true_evo_type.value - - info_row_2 = '**Rarity** {}\n**Cost** {}'.format(m.rarity, m.cost) - if acquire_text: - info_row_2 += '\n**{}**'.format(acquire_text) - if m.is_inheritable: - info_row_2 += '\n**Inheritable**' - else: - info_row_2 += '\n**Not inheritable**' - if tet_text: - info_row_2 += '\n**{}**'.format(tet_text) - - embed.add_field(name=info_row_1, value=info_row_2) - - hp, atk, rcv, weighted = m.stats() - if m.limit_mult > 0: - lb_hp, lb_atk, lb_rcv, lb_weighted = m.stats(lv=110) - stats_row_1 = 'Weighted {} | LB {} (+{}%)'.format(weighted, lb_weighted, m.limit_mult) - stats_row_2 = '**HP** {} ({})\n**ATK** {} ({})\n**RCV** {} ({})'.format( - hp, lb_hp, atk, lb_atk, rcv, lb_rcv) - else: - stats_row_1 = 'Weighted {}'.format(weighted) - stats_row_2 = '**HP** {}\n**ATK** {}\n**RCV** {}'.format(hp, atk, rcv) - embed.add_field(name=stats_row_1, value=stats_row_2) - - awakenings_row = '' - for idx, a in enumerate(m.awakenings): - as_id = a.awoken_skill_id - as_name = a.name - mapped_awakening = AWAKENING_MAP.get(as_id, as_name) - mapped_awakening = match_emoji(emoji_list, mapped_awakening) - - # Wrap superawakenings to the next line - if len(m.awakenings) - idx == m.superawakening_count: - awakenings_row += '\n{}'.format(mapped_awakening) + @commands.command() + async def jpdgname(self, ctx, *, search_text): + """Show the JP name of a dungeons""" + dbcog = await self.get_dbcog() + db: "DBCogDatabase" = dbcog.database.database + + qs = await QuerySettings.extract_raw(ctx.author, self.bot, search_text) + sds = await self.get_subdungeons(search_text, db) + if not sds: + return await ctx.send(f"No dungeons found") + + dungeons = self.make_dungeon_dict(sds) + + menu = ClosableEmbedMenu.menu() + props = JpDungeonNameViewProps(sorted(dungeons.values(), key=lambda d: d['idx'])) + state = ClosableEmbedViewState(ctx.message.author.id, ClosableEmbedMenu.MENU_TYPE, search_text, + qs, JpDungeonNameView.VIEW_TYPE, props) + return await menu.create(ctx, state) + + @commands.command(aliases=["jydl", "jpyt"], usage=" / ") + async def jpyoutube(self, ctx, *, search_text): + """Attempt to link to a YouTube search of a leader in a dungeon""" + dbcog = await self.get_dbcog() + db: "DBCogDatabase" = dbcog.database.database + if '/' in search_text: + texts = search_text.split('/') + elif ',' in search_text: + texts = search_text.split('/') else: - awakenings_row += ' {}'.format(mapped_awakening) - - awakenings_row = awakenings_row.strip() - - if not len(awakenings_row): - awakenings_row = 'No Awakenings' - - killers_row = '**Available Killers:** {}'.format(' '.join(m.killers)) - - embed.description = '{}\n{}'.format(awakenings_row, killers_row) - - active_header = 'Active Skill' - active_body = 'None' - active_skill = m.active_skill - if active_skill: - active_header = 'Active Skill ({} -> {})'.format(active_skill.turn_max, - active_skill.turn_min) - active_body = active_skill.desc - embed.add_field(name=active_header, value=active_body, inline=False) - - leader_skill = m.leader_skill - ls_row = m.leader_skill.desc if leader_skill else 'None' - ls_header = 'Leader Skill' - if leader_skill: - hp, atk, rcv, resist = m.leader_skill.data - multiplier_text = createMultiplierText(hp, atk, rcv, resist) - ls_header += " [ {} ]".format(multiplier_text) - embed.add_field(name=ls_header, value=ls_row, inline=False) - - return embed - - -def monsterToOtherInfoEmbed(m: "DgMonster"): - embed = monsterToBaseEmbed(m) - # Clear the thumbnail, takes up too much space - embed.set_thumbnail(url='') - - body_text = '\n' - stat_cols = ['', 'HP', 'ATK', 'RCV'] - for plus in (0, 297): - body_text += '**Stats at +{}:**'.format(plus) - tbl = prettytable.PrettyTable(stat_cols) - tbl.hrules = prettytable.NONE - tbl.vrules = prettytable.NONE - tbl.align = "l" - levels = (m.level, 110) if m.limit_mult > 0 else (m.level,) - for lv in levels: - for inh in (False, True): - hp, atk, rcv, _ = m.stats(lv, plus=plus, inherit=inh) - row_name = 'Lv{}'.format(lv) - if inh: - row_name = '(Inh)' - tbl.add_row([row_name.format(plus), hp, atk, rcv]) - body_text += box(tbl.get_string()) - - search_text = YT_SEARCH_TEMPLATE.format(urllib.parse.quote(m.name_jp)) - skyozora_text = SKYOZORA_TEMPLATE.format(m.monster_no_jp) - body_text += "\n**JP Name**: {} | [YouTube]({}) | [Skyozora]({})".format( - m.name_jp, search_text, skyozora_text) - - if m.history_us: - body_text += '\n**History:** {}'.format(m.history_us) - - body_text += '\n**Series:** {}'.format(m.series.name) - body_text += '\n**Sell MP:** {:,}'.format(m.sell_mp) - if m.buy_mp is not None: - body_text += " **Buy MP:** {:,}".format(m.buy_mp) - - if m.exp < 1000000: - xp_text = '{:,}'.format(m.exp) - else: - xp_text = '{:.1f}'.format(m.exp / 1000000).rstrip('0').rstrip('.') + 'M' - body_text += '\n**XP to Max:** {}'.format(xp_text) - body_text += ' **Max Level:**: {}'.format(m.level) - body_text += '\n**Rarity:** {} **Cost:** {}'.format(m.rarity, m.cost) - - embed.description = body_text - - return embed - - -AWAKENING_MAP = { - 1: 'boost_hp', - 2: 'boost_atk', - 3: 'boost_rcv', - 4: 'reduce_fire', - 5: 'reduce_water', - 6: 'reduce_wood', - 7: 'reduce_light', - 8: 'reduce_dark', - 9: 'misc_autoheal', - 10: 'res_bind', - 11: 'res_blind', - 12: 'res_jammer', - 13: 'res_poison', - 14: 'oe_fire', - 15: 'oe_water', - 16: 'oe_wood', - 17: 'oe_light', - 18: 'oe_dark', - 19: 'misc_te', - 20: 'misc_bindclear', - 21: 'misc_sb', - 22: 'row_fire', - 23: 'row_water', - 24: 'row_wood', - 25: 'row_light', - 26: 'row_dark', - 27: 'misc_tpa', - 28: 'res_skillbind', - 29: 'oe_heart', - 30: 'misc_multiboost', - 31: 'killer_dragon', - 32: 'killer_god', - 33: 'killer_devil', - 34: 'killer_machine', - 35: 'killer_balance', - 36: 'killer_attacker', - 37: 'killer_physical', - 38: 'killer_healer', - 39: 'killer_evomat', - 40: 'killer_awoken', - 41: 'killer_enhancemat', - 42: 'killer_vendor', - 43: 'misc_comboboost', - 44: 'misc_guardbreak', - 45: 'misc_extraattack', - 46: 'teamboost_hp', - 47: 'teamboost_rcv', - 48: 'misc_voidshield', - 49: 'misc_assist', - 50: 'misc_super_extraattack', - 51: 'misc_skillcharge', - 52: 'res_bind_super', - 53: 'misc_te_super', - 54: 'res_cloud', - 55: 'res_seal', - 56: 'misc_sb_super', - 57: 'attack_boost_high', - 58: 'attack_boost_low', - 59: 'l_shield', - 60: 'l_attack', - 61: 'misc_super_comboboost', - 62: 'orb_combo', - 63: 'misc_voice', - 64: 'misc_dungeonbonus', - 65: 'reduce_hp', - 66: 'reduce_atk', - 67: 'reduce_rcv', - 68: 'res_blind_super', - 69: 'res_jammer_super', - 70: 'res_poison_super', - 71: 'misc_jammerboost', - 72: 'misc_poisonboost', -} - - -def createMultiplierText(hp1, atk1, rcv1, resist1, hp2=None, atk2=None, rcv2=None, resist2=None): - if all([x is None for x in (hp2, atk2, rcv2, resist2)]): - hp2, atk2, rcv2, resist2 = hp1, atk1, rcv1, resist1 - - def fmtNum(val): - return ('{:.2f}').format(val).strip('0').rstrip('.') - - text = "{}/{}/{}".format(fmtNum(hp1 * hp2), fmtNum(atk1 * atk2), fmtNum(rcv1 * rcv2)) - if resist1 > 0 or resist2 > 0: - text += ' Resist {}%'.format(fmtNum(100 * (1 - (1 - resist1) * (1 - resist2)))) - return text + texts = search_text.split(maxsplit=1) + if len(texts) < 2: + return await ctx.send(f"No monster found. Please provide both a dungeon and a monster. " + f"Perhaps you meant to use `{ctx.prefix}skyo` or `{ctx.prefix}jpdgname`?") + dg_text = texts[0] + mon_text = texts[1] + dg_qs = await QuerySettings.extract_raw(ctx.author, self.bot, dg_text) + + monster = await dbcog.find_monster(mon_text, ctx.author.id) + if monster is None: + return await ctx.send(f"No monster found. This command looks for `/` or `,` as a delimiter, " + f"maybe try again?") + + sds = await self.get_subdungeons(dg_text, db) + if not sds: + return await ctx.send(f"No dungeons found. This command looks for `/` or `,` as a delimiter, " + f"maybe try again?") + + dungeons = self.make_dungeon_dict(sds) + + menu = ClosableEmbedMenu.menu() + props = JpYtDgLeadProps(sorted(dungeons.values(), key=lambda d: d['idx']), monster) + state = ClosableEmbedViewState(ctx.message.author.id, ClosableEmbedMenu.MENU_TYPE, search_text, + dg_qs, JpYtDgLeadView.VIEW_TYPE, props) + return await menu.create(ctx, state) + + async def get_subdungeons(self, search_text, db): + await self.aliases_loaded.wait() + if search_text.replace(' ', '') not in self.aliases: + formatted_text = f'%{search_text}%' + sds = db.query_many( + 'SELECT dungeons.dungeon_id, sub_dungeon_id, dungeons.name_ja AS dg_name_ja, sub_dungeons.name_ja AS sd_name_ja,' + ' dungeons.name_en AS dg_name_en, sub_dungeons.name_en AS sd_name_en' + ' FROM sub_dungeons' + ' JOIN dungeons ON sub_dungeons.dungeon_id = dungeons.dungeon_id' + ' WHERE LOWER(dungeons.name_en) LIKE ? OR LOWER(dungeons.name_ja) LIKE ?' + ' OR LOWER(sub_dungeons.name_en) LIKE ? OR LOWER(sub_dungeons.name_ja) LIKE ?' + ' ORDER BY dungeons.dungeon_id LIMIT 20', (formatted_text,) * 4) + return sds + formatted_text = ', '.join(a for a in self.aliases[search_text.replace(' ', '')] if a.isnumeric()) + sds = db.query_many( + 'SELECT dungeons.dungeon_id, sub_dungeon_id, dungeons.name_ja AS dg_name_ja, sub_dungeons.name_ja AS sd_name_ja,' + ' dungeons.name_en AS dg_name_en, sub_dungeons.name_en AS sd_name_en' + ' FROM sub_dungeons' + ' JOIN dungeons ON sub_dungeons.dungeon_id = dungeons.dungeon_id' + f' WHERE dungeons.dungeon_id IN ({formatted_text}) OR sub_dungeon_id IN ({formatted_text})' + ' ORDER BY dungeons.dungeon_id LIMIT 20') + return sds + + @staticmethod + def make_dungeon_dict(sds): + dungeons = defaultdict(lambda: {'subdungeons': []}) + for sd in sds: + dungeons[sd.dungeon_id]['name'] = sd.dg_name_ja + dungeons[sd.dungeon_id]['name_en'] = sd.dg_name_en + dungeons[sd.dungeon_id]['idx'] = sd.dungeon_id + dungeons[sd.dungeon_id]['subdungeons'].append({ + 'name': sd.sd_name_ja, + 'name_en': sd.sd_name_en, + 'idx': sd.sub_dungeon_id}) + return dungeons + + @commands.command(aliases=['firdg']) + @auth_check('contentadmin') + async def force_dungeon_index_reload(self, ctx): + async with ctx.typing(): + await self.load_aliases() + await ctx.send("Reloaded") diff --git a/padinfo/reaction_list.py b/padinfo/reaction_list.py new file mode 100644 index 00000000..4e50daa2 --- /dev/null +++ b/padinfo/reaction_list.py @@ -0,0 +1,31 @@ +from typing import List, Optional, TYPE_CHECKING + +from padinfo.core.padinfo_settings import settings +from padinfo.menu.id import IdMenuEmoji, IdMenuPanes +from padinfo.view.evos import EvosViewState +from padinfo.view.materials import MaterialsViewState +from padinfo.view.pantheon import PantheonViewState + +if TYPE_CHECKING: + from dbcog.models.monster_model import MonsterModel + + +async def get_id_menu_initial_reaction_list(ctx, dbcog, monster: "MonsterModel", + full_reaction_list: List[Optional[str]] = None, force_evoscroll=False): + # hide some panes if we're in evo scroll mode + if not full_reaction_list: + full_reaction_list = IdMenuPanes.emoji_names() + if not force_evoscroll and not settings.checkEvoID(ctx.author.id): + return full_reaction_list + alt_versions, gem_versions = await EvosViewState.do_query(dbcog, monster) + if alt_versions is None and gem_versions is None: + full_reaction_list.remove(IdMenuEmoji.left) + full_reaction_list.remove(IdMenuEmoji.right) + full_reaction_list.remove(IdMenuEmoji.evos) + pantheon_list, _, _ = await PantheonViewState.do_query(dbcog, monster) + if pantheon_list is None: + full_reaction_list.remove(IdMenuEmoji.pantheon) + mats, usedin, gemid, _, skillups, _, _, _ = await MaterialsViewState.do_query(dbcog, monster) + if mats is None and usedin is None and gemid is None and skillups is None: + full_reaction_list.remove(IdMenuEmoji.mats) + return full_reaction_list diff --git a/padinfo/view/__init__.py b/padinfo/view/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padinfo/view/awakening_help.py b/padinfo/view/awakening_help.py new file mode 100644 index 00000000..3cbc7314 --- /dev/null +++ b/padinfo/view/awakening_help.py @@ -0,0 +1,93 @@ +from typing import TYPE_CHECKING + +from discordmenu.embed.base import Box +from discordmenu.embed.components import EmbedAuthor, EmbedField, EmbedMain +from discordmenu.embed.text import Text +from discordmenu.embed.view import EmbedView +from tsutils.menu.components.footers import embed_footer_with_state +from tsutils.menu.view.closable_embed import ClosableEmbedViewState +from tsutils.tsubaki.custom_emoji import get_awakening_emoji +from tsutils.tsubaki.links import MonsterImage, MonsterLink +from tsutils.tsubaki.monster_header import MonsterHeader + +from padinfo.view.common import get_awoken_skill_description + +if TYPE_CHECKING: + from dbcog.models.monster_model import MonsterModel + from dbcog.models.awakening_model import AwakeningModel + +ORDINAL_WORDS = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth'] + + +class AwakeningHelpViewProps: + def __init__(self, monster: "MonsterModel", token_map: dict): + self.monster = monster + + # this is the list of allowed modifiers from token_mappings dbcog file (cannot import it bc redbot rules) + self.token_map = {} + for k in token_map.keys(): + self.token_map[k.value] = token_map[k] + + +def _get_repeated_desc(prev_index: int, awakening: "AwakeningModel"): + emoji_text = get_awakening_emoji(awakening.awoken_skill_id, awakening.name) + return Box( + Text(emoji_text), + Text('[Same as {} awakening]'.format(ORDINAL_WORDS[prev_index])), + delimiter=' ' + ) + + +def _get_all_awakening_descs(awakening_list, show_help, token_map): + appearances = {} + awakening_descs = [] + for index, awakening in enumerate(awakening_list): + if awakening.name not in appearances: + appearances[awakening.name] = index + awakening_descs.append( + get_awoken_skill_description( + awakening.awoken_skill, show_help=show_help, token_map=token_map)) + else: + awakening_descs.append(_get_repeated_desc(appearances[awakening.name], awakening)) + + return Box(*awakening_descs) + + +def get_normal_awakenings(monster: "MonsterModel", show_help: bool, token_map: dict): + normal_awakening_count = len(monster.awakenings) - monster.superawakening_count + normal_awakenings = monster.awakenings[:normal_awakening_count] + return _get_all_awakening_descs(normal_awakenings, show_help, token_map) + + +def get_super_awakenings(monster: "MonsterModel", show_help: bool, token_map: dict): + normal_awakening_count = len(monster.awakenings) - monster.superawakening_count + super_awakenings = monster.awakenings[normal_awakening_count:] + return _get_all_awakening_descs(super_awakenings, show_help, token_map) + + +class AwakeningHelpView: + VIEW_TYPE = 'AwakeningHelp' + + @staticmethod + def embed(state: ClosableEmbedViewState, props: AwakeningHelpViewProps): + monster = props.monster + show_help = state.query_settings.showhelp.value + token_map = props.token_map + fields = [ + EmbedField('Normal Awakenings', get_normal_awakenings(monster, show_help, token_map)), + EmbedField('Super Awakenings', get_super_awakenings(monster, show_help, token_map)) + ] + + return EmbedView( + EmbedMain( + color=state.query_settings.embedcolor, + description='This monster has no awakenings.' if not monster.awakenings else '' + ), + embed_author=EmbedAuthor( + MonsterHeader.menu_title(monster).to_markdown(), + MonsterLink.header_link(monster, query_settings=state.query_settings), + MonsterImage.icon(monster.monster_id) + ), + embed_footer=embed_footer_with_state(state), + embed_fields=fields + ) diff --git a/padinfo/view/awakening_list.py b/padinfo/view/awakening_list.py new file mode 100644 index 00000000..0d5dc341 --- /dev/null +++ b/padinfo/view/awakening_list.py @@ -0,0 +1,100 @@ +from typing import List + +from discordmenu.embed.base import Box +from discordmenu.embed.components import EmbedField, EmbedMain +from discordmenu.embed.view import EmbedView +from tsutils.menu.components.config import UserConfig +from tsutils.menu.components.footers import embed_footer_with_state +from tsutils.menu.view.view_state_base import ViewStateBase +from tsutils.query_settings.query_settings import QuerySettings + +from padinfo.view.common import get_awoken_skill_description + + +class AwakeningListSortTypes: + alphabetical = "Alphabetical" + numerical = "Numerical" + + +class AwakeningListViewState(ViewStateBase): + MAX_ITEMS_PER_PANE = 10 + + def __init__(self, original_author_id, menu_type, query_settings: QuerySettings, + sort_type, paginated_skills, current_page, + token_map: dict, + extra_state=None, + reaction_list: List[str] = None): + super().__init__(original_author_id, menu_type, '', extra_state=extra_state, reaction_list=reaction_list) + self.sort_type = sort_type + self.paginated_skills = paginated_skills + self.total_pages = len(self.paginated_skills) + self.current_page = current_page + self.query_settings = query_settings + + # this is the list of allowed modifiers from token_mappings dbcog file (cannot import it bc redbot rules) + self.token_map = {} + for k in token_map.keys(): + self.token_map[k.value] = token_map[k] + + def serialize(self): + ret = super().serialize() + ret.update({ + 'current_page': self.current_page, + 'total_pages': self.total_pages, + 'sort_type': self.sort_type, + 'query_settings': self.query_settings.serialize(), + }) + return ret + + @classmethod + async def deserialize(cls, dbcog, _user_config: UserConfig, ims: dict): + sort_type = ims['sort_type'] + paginated_skills = await cls.do_query(dbcog, sort_type) + original_author_id = ims['original_author_id'] + current_page = ims['current_page'] + menu_type = ims['menu_type'] + reaction_list = ims['reaction_list'] + token_map = dbcog.AWOKEN_SKILL_TOKEN_MAP + query_settings = QuerySettings.deserialize(ims.get('query_settings')) + return AwakeningListViewState(original_author_id, menu_type, query_settings, sort_type, paginated_skills, + current_page, token_map, reaction_list=reaction_list) + + @classmethod + async def do_query(cls, dbcog, sort_type): + awoken_skills = dbcog.database.get_all_awoken_skills() + if sort_type == AwakeningListSortTypes.alphabetical: + awoken_skills = sorted(awoken_skills, key=lambda awo: awo.name_en) + elif sort_type == AwakeningListSortTypes.numerical: + awoken_skills = sorted(awoken_skills, key=lambda awo: awo.awoken_skill_id) + + paginated_skills = [awoken_skills[i:i + AwakeningListViewState.MAX_ITEMS_PER_PANE] + for i in range(0, len(awoken_skills), AwakeningListViewState.MAX_ITEMS_PER_PANE)] + return paginated_skills + + +class AwakeningListView: + VIEW_TYPE = 'AwakeningList' + + @staticmethod + def embed(state: AwakeningListViewState): + fields = [ + EmbedField( + 'Awakenings - by {}'.format( + 'name' if state.sort_type == AwakeningListSortTypes.alphabetical else 'id number'), + Box(*[get_awoken_skill_description( + awo, + show_help=state.query_settings.showhelp.value, + token_map=state.token_map) for awo in state.paginated_skills[state.current_page]] + )), + EmbedField('Page', Box( + '{} of {}'.format(str(state.current_page + 1), str(len(state.paginated_skills))) + )) + ] + + return EmbedView( + EmbedMain( + color=state.query_settings.embedcolor, + ), + embed_footer=embed_footer_with_state(state), + embed_fields=fields + ) diff --git a/padinfo/view/base.py b/padinfo/view/base.py new file mode 100644 index 00000000..da866a6f --- /dev/null +++ b/padinfo/view/base.py @@ -0,0 +1,15 @@ +from abc import ABCMeta, abstractmethod + +from discordmenu.embed.view import EmbedView + +from padinfo.view.components.view_state_base_id import ViewStateBaseId + + +class BaseIdView(metaclass=ABCMeta): + VIEW_TYPE: str + TSUBAKI = 2141 + + @classmethod + @abstractmethod + def embed(cls, state: ViewStateBaseId) -> EmbedView: + ... diff --git a/padinfo/view/button_info.py b/padinfo/view/button_info.py new file mode 100644 index 00000000..301fc3aa --- /dev/null +++ b/padinfo/view/button_info.py @@ -0,0 +1,213 @@ +from typing import List, TYPE_CHECKING + +from discordmenu.embed.base import Box +from discordmenu.embed.components import EmbedAuthor, EmbedField, EmbedMain +from discordmenu.embed.text import BlockText, Text +from discordmenu.embed.view import EmbedView +from tsutils.menu.components.config import UserConfig +from tsutils.menu.components.footers import embed_footer_with_state +from tsutils.menu.view.view_state_base import ViewStateBase +from tsutils.query_settings.query_settings import QuerySettings +from tsutils.tsubaki.links import MonsterImage, MonsterLink +from tsutils.tsubaki.monster_header import MonsterHeader + +from padinfo.core.button_info import ButtonInfoResult, ButtonInfoStatSet, LIMIT_BREAK_LEVEL, SUPER_LIMIT_BREAK_LEVEL, \ + button_info +from padinfo.view.components.evo_scroll_mixin import EvoScrollView, EvoScrollViewState, MonsterEvolution + +if TYPE_CHECKING: + from dbcog.models.monster_model import MonsterModel + + +class ButtonInfoOptions: + coop = 'Multiplayer' + solo = 'Singleplayer' + desktop = 'Desktop' + mobile = 'Mobile' + limit_break = 'Level 110' + super_limit_break = 'Level 120' + + +class ButtonInfoToggles: + def __init__(self, players_setting=ButtonInfoOptions.coop, device_setting=ButtonInfoOptions.desktop, + max_level_setting=ButtonInfoOptions.limit_break): + self.players = players_setting + self.device = device_setting + self.max_level = max_level_setting + + +class ButtonInfoViewState(ViewStateBase, EvoScrollViewState): + def __init__(self, original_author_id, menu_type, raw_query, display_options: ButtonInfoToggles, + monster: "MonsterModel", alt_monsters: List[MonsterEvolution], + info: ButtonInfoResult, query_settings: QuerySettings, + reaction_list: List[str] = None): + super().__init__(original_author_id, menu_type, raw_query, extra_state=None, reaction_list=reaction_list) + self.display_options = display_options + self.monster = monster + self.info = info + self.query_settings = query_settings + + self.alt_monsters = alt_monsters + self.alt_monster_ids = [m.monster.monster_id for m in self.alt_monsters] + + def serialize(self): + ret = super().serialize() + ret.update({ + # maybe serialize the object? look into how it's done for query settings + 'players_setting': self.display_options.players, + 'device_setting': self.display_options.device, + 'max_level_setting': self.display_options.max_level, + 'resolved_monster_id': self.monster.monster_id, + 'query_settings': self.query_settings.serialize() + }) + return ret + + @classmethod + async def deserialize(cls, dbcog, user_config: UserConfig, ims: dict): + raw_query = ims['raw_query'] + original_author_id = ims['original_author_id'] + menu_type = ims['menu_type'] + query_settings = QuerySettings.deserialize(ims['query_settings']) + display_options = ButtonInfoToggles(ims['players_setting'], ims['device_setting'], ims['max_level_setting']) + monster = dbcog.get_monster(int(ims['resolved_monster_id'])) + alt_monsters = cls.get_alt_monsters_and_evos(dbcog, monster) + info = button_info.get_info(dbcog, monster) + reaction_list = ims['reaction_list'] + return ButtonInfoViewState(original_author_id, menu_type, raw_query, display_options, + monster, alt_monsters, info, query_settings, reaction_list=reaction_list) + + def set_player_count(self, new_count): + self.display_options.players = new_count + + def set_device(self, new_device): + self.display_options.device = new_device + + def set_max_level(self, new_max_level): + self.display_options.max_level = new_max_level + + +def get_stat_block(bi_stat_set: ButtonInfoStatSet): + return BlockText( + Box( + Text('Base: {}'.format(int(round(bi_stat_set.main)))), + Text('Subattr: {}'.format(int(round(bi_stat_set.sub)))), + Text('Total: {}'.format(int(round(bi_stat_set.total)))) + ) + ) + + +def get_mobile_btn_str(btn_str): + output = [] + lines = btn_str.split('\n') + for line in lines: + partition = line.find('(') + name = line[:partition].strip() + damage = line[partition:] + output.append('{}\n {}'.format(name, damage)) + return '\n'.join(output) + + +class ButtonInfoView(EvoScrollView): + VIEW_TYPE = 'ButtonInfo' + + @staticmethod + def get_max_level_text(monster, is_max_110): + limit = str(LIMIT_BREAK_LEVEL) if is_max_110 else str(SUPER_LIMIT_BREAK_LEVEL) + level_text = limit if monster.limit_mult != 0 else 'Max ({})'.format(monster.level) + return 'Lv. {}'.format(level_text) + + @staticmethod + def get_common_buttons_title_text(monster, is_max_110): + return 'Common Buttons - Base Card {} {}'.format( + ButtonInfoView.get_max_level_text(monster, is_max_110), + '(Atk+ Latents)' if is_max_110 or monster.limit_mult == 0 else '(Atk++ Latents)' + ) + + @classmethod + def embed(cls, state: ButtonInfoViewState): + is_coop = state.display_options.players == ButtonInfoOptions.coop + is_desktop = state.display_options.device == ButtonInfoOptions.desktop + max_110 = state.display_options.max_level == ButtonInfoOptions.limit_break + monster = state.monster + info = state.info + + fields = [ + EmbedField( + # this block does not change if the lv110/lv120 toggle is clicked + ButtonInfoView.get_max_level_text(monster, True), + Box( + Text('Without Latents'), + # avoid whitespace after code block + Box( + get_stat_block(info.coop if is_coop else info.solo), + Text('With Latents (Atk+)'), + delimiter='' + ), + get_stat_block(info.coop_latent if is_coop else info.solo_latent) + ), + inline=True + ), + EmbedField( + 'Lv. 120', + Box( + Text('Without Latents'), + # avoid whitespace after code block + Box( + get_stat_block(info.coop_slb if is_coop else info.solo_slb), + Text('With Latents (Atk++)'), + delimiter='' + ), + get_stat_block(info.coop_slb_latent if is_coop else info.solo_slb_latent) + ), + inline=True + ) if monster.limit_mult != 0 else None, + EmbedField( + ButtonInfoView.get_common_buttons_title_text(monster, max_110), + Box( + Text('*Inherits are assumed to be the max possible level (up to 110) and +297.*'), + # janky, but python gives DeprecationWarnings when using \* in a regular string + Text(r'*\* = on-color stat bonus applied*'), + Text('Card Button Damage'), + # done this way to not have the whitespace after code block + Box( + BlockText(info.get_card_btn_str(is_coop, max_110)), + Text('Team Button Contribution'), + delimiter='' + ), + BlockText(info.get_team_btn_str(is_coop, max_110)) + ) + ) if is_desktop else None, + EmbedField( + ButtonInfoView.get_common_buttons_title_text(monster, max_110), + Box( + Text('*Inherits are assumed to be the max possible level (up to 110) and +297.*'), + # janky, but python gives DeprecationWarnings when using \* in a regular string + Text(r'*\* = on-color stat bonus applied*') + ) + ) if not is_desktop else None, + EmbedField( + 'Card Button Damage', + BlockText(get_mobile_btn_str(info.get_card_btn_str(is_coop, max_110))), + inline=True + ) if not is_desktop else None, + EmbedField( + 'Team Button Contribution', + BlockText(get_mobile_btn_str(info.get_team_btn_str(is_coop, max_110))), + inline=True + ) if not is_desktop else None, + cls.evos_embed_field(state) + ] + + return EmbedView( + EmbedMain( + color=state.query_settings.embedcolor, + description='(Co-op mode)' if is_coop else '(Singleplayer mode)' + ), + embed_author=EmbedAuthor( + MonsterHeader.menu_title(monster).to_markdown(), + MonsterLink.header_link(monster, state.query_settings), + MonsterImage.icon(monster.monster_id) + ), + embed_footer=embed_footer_with_state(state), + embed_fields=fields + ) diff --git a/padinfo/view/common.py b/padinfo/view/common.py new file mode 100644 index 00000000..f92470af --- /dev/null +++ b/padinfo/view/common.py @@ -0,0 +1,49 @@ +from typing import TYPE_CHECKING + +from discordmenu.embed.base import Box +from discordmenu.embed.text import BoldText, Text +from tsutils.query_settings.query_settings import QuerySettings +from tsutils.tsubaki.custom_emoji import get_awakening_emoji +from tsutils.tsubaki.monster_header import MonsterHeader + +if TYPE_CHECKING: + from dbcog.models.monster_model import MonsterModel + from dbcog.models.awakening_model import AwokenSkillModel + + +async def get_monster_from_ims(dbcog, ims: dict): + query = ims.get('query') or ims['raw_query'] + query_settings = QuerySettings.deserialize(ims.get('query_settings')) + + resolved_monster_id_str = ims.get('resolved_monster_id') + resolved_monster_id = int(resolved_monster_id_str or 0) + if resolved_monster_id: + return dbcog.database.graph.get_monster(resolved_monster_id, server=query_settings.server) + monster = await dbcog.find_monster(query, ims['original_author_id']) + return monster + + +def get_awoken_skill_description(awoken_skill: "AwokenSkillModel", show_help: bool = False, token_map: dict = None): + if token_map is None: + token_map = {} + emoji_text = get_awakening_emoji(awoken_skill.awoken_skill_id, awoken_skill.name) + if not show_help: + desc = awoken_skill.desc_en + else: + if awoken_skill.awoken_skill_id not in token_map: + desc = "No modifiers found. Please contact Tsubaki admins for support." + else: + tokens = token_map[awoken_skill.awoken_skill_id] + tokens: set + desc = ', '.join('`{}`'.format(token) for token in list(tokens)) + return Box( + Text(emoji_text), + BoldText(awoken_skill.name_en), + Text(desc), + delimiter=' ' + ) + + +def invalid_monster_text(query: str, monster: "MonsterModel", append_text: str): + monster_name = MonsterHeader.text_with_emoji(monster) + return f'Your query `{query}` found {monster_name}{append_text}.' diff --git a/padinfo/view/components/__init__.py b/padinfo/view/components/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padinfo/view/components/base_id_main_view.py b/padinfo/view/components/base_id_main_view.py new file mode 100644 index 00000000..e7e53f7f --- /dev/null +++ b/padinfo/view/components/base_id_main_view.py @@ -0,0 +1,167 @@ +from abc import ABC, abstractmethod +from typing import Dict, List, Optional, TYPE_CHECKING + +import jinja2 +from discordmenu.embed.base import Box +from discordmenu.embed.text import BoldText, Text +from discordmenu.embed.view import EmbedView +from discordmenu.emoji.emoji_cache import emoji_cache +from tsutils.query_settings.enums import LsMultiplier, SkillDisplay +from tsutils.tsubaki.custom_emoji import get_awakening_emoji, get_emoji, number_emoji_small + +from padinfo.core.leader_skills import ls_multiplier_text, ls_single_multiplier_text +from padinfo.view.base import BaseIdView +from padinfo.view.components.view_state_base_id import ViewStateBaseId + +if TYPE_CHECKING: + from dbcog.models.leader_skill_model import LeaderSkillModel + from dbcog.models.monster_model import MonsterModel + from dbcog.models.active_skill_model import ActiveSkillModel + from dbcog.models.awakening_model import AwakeningModel + from dbcog.models.awoken_skill_model import AwokenSkillModel + + +def _get_awakening_text(awakening: "AwakeningModel"): + return get_awakening_emoji(awakening.awoken_skill_id, awakening.name) + + +def _killer_latent_emoji(latent_name: str): + return get_emoji('latent_killer_{}'.format(latent_name.lower())) + + +def _make_prefix(idx, skill, subskill): + if skill.compound_skill_type_id == 1: + return emoji_cache.get_emoji('bd') + ' ' + elif skill.compound_skill_type_id in (2, 3): + return number_emoji_small(idx) + (f' **[{subskill.cooldown}cd]** ' if idx != 1 else ' ') + return '' + + +def _make_suffix(idx, skill, subskill): + if subskill.board_76 != "Z" * (7 * 6): + return (f" [see 6x5](https://pad.dawnglare.com/?patt={subskill.board_65}&showfill=1) ·" + f" [see 7x6](https://pad.dawnglare.com/?patt={subskill.board_76}&showfill=1&height=6&width=7)") + return "" + + +class BaseIdMainView(BaseIdView, ABC): + transform_emoji_names = ['downr', 'downo', 'downy', 'downg', 'downb', 'downp'] + up_emoji_name = 'upgr' + down_emoji_name = transform_emoji_names[0] + + active_skill_type_texts = { + 0: None, + 1: f"[\N{GAME DIE} Random]", + 2: f"[\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE} Transforms]", + 3: f"[\N{BLACK UNIVERSAL RECYCLING SYMBOL}\N{VARIATION SELECTOR-16} Cycles]" + } + + @staticmethod + def normal_awakenings_row(m: "MonsterModel"): + normal_awakenings = len(m.awakenings) - m.superawakening_count + normal_awakenings_emojis = [_get_awakening_text(a) for a in m.awakenings[:normal_awakenings]] + return Box(*[Text(e) for e in normal_awakenings_emojis], delimiter=' ') + + @staticmethod + def super_awakenings_row(m: "MonsterModel"): + normal_awakenings = len(m.awakenings) - m.superawakening_count + super_awakenings_emojis = [_get_awakening_text(a) for a in m.awakenings[normal_awakenings:]] + return Box( + Text(get_emoji('sa_questionmark')), + *[Text(e) for e in super_awakenings_emojis], + delimiter=' ') if len(super_awakenings_emojis) > 0 else None + + @staticmethod + def killers_row(m: "MonsterModel", transform_base): + killers = m.killers if m == transform_base else transform_base.killers + killers_text = 'Any' if 'Any' in killers else \ + ' '.join(_killer_latent_emoji(k) for k in killers) + return Box( + BoldText('Available killers:'), + Text('\N{DOWN-POINTING RED TRIANGLE}' if m != transform_base else ''), + Text('[{} slots]'.format(m.latent_slots if m == transform_base + else transform_base.latent_slots)), + Text(killers_text), + delimiter=' ' + ) + + @classmethod + def active_skill_header(cls, m: "MonsterModel", previous_transforms: List["MonsterModel"]): + + active_skill = m.active_skill + if active_skill is None: + return BoldText('Active Skill') + + if len(previous_transforms) == 0: + active_cd = "({} -> {})".format(active_skill.cooldown_turns_max, active_skill.cooldown_turns_min) + else: + skill_texts = [] + previous_transforms.reverse() + for i, mon in enumerate(previous_transforms): + skill = mon.active_skill + # we can assume skill is not None because the monster transforms + cooldown_text = '({}cd)'.format(str(skill.cooldown_turns_max)) + if skill.cooldown_turns_min != skill.cooldown_turns_max: + cooldown_text = '{} -> {}'.format(skill.cooldown_turns_max, skill.cooldown_turns_min) + skill_texts.append( + '{}{}'.format(get_emoji(cls.transform_emoji_names[i % len(cls.transform_emoji_names)]), + cooldown_text)) + skill_texts.append('{} ({} cd)'.format(get_emoji(cls.up_emoji_name), m.active_skill.cooldown_turns_max)) + active_cd = ' '.join(skill_texts) + + return Box( + BoldText('Active Skill'), + cls.get_compound_active_text(active_skill), + BoldText(active_cd), + delimiter=' ' + ) + + @classmethod + def get_compound_active_text(cls, active: Optional["ActiveSkillModel"]) -> Optional[Box]: + text = cls.active_skill_type_texts.get(active.compound_skill_type_id) + if text is None: + return None + return BoldText(text) + + @classmethod + def active_skill_text(cls, active_skill: Optional["ActiveSkillModel"], + awoken_skill_map: Dict[int, "AwokenSkillModel"], + skilldisplay: SkillDisplay): + if skilldisplay == SkillDisplay.skillnames: + return active_skill.name_en + jinja2_replacements = { + 'awoskills': {f"id{awid}": f"{get_awakening_emoji(awid)} {awo.name_en}" + for awid, awo in awoken_skill_map.items()} + } + + if active_skill is None: + return 'None' + return "\n".join(_make_prefix(c, active_skill, subskill) + + jinja2.Template(subskill.desc_templated).render(**jinja2_replacements) + + _make_suffix(c, active_skill, subskill) + for c, subskill in enumerate(active_skill.active_subskills, 1)) + + @staticmethod + def leader_skill_header(m: "MonsterModel", lsmultiplier: LsMultiplier, transform_base: "MonsterModel"): + return Box( + BoldText('Leader Skill'), + BoldText(ls_multiplier_text(m.leader_skill) if lsmultiplier == LsMultiplier.lsdouble + else get_emoji('1x') + ' ' + ls_single_multiplier_text(m.leader_skill)), + BoldText('(' + get_emoji( + '\N{DOWN-POINTING RED TRIANGLE}') + '7x6)') if m != transform_base and transform_base.leader_skill.is_7x6 else None, + delimiter=' ' + ) + + @classmethod + def leader_skill_text(cls, leader_skill: "LeaderSkillModel", skilldisplay: SkillDisplay): + if leader_skill is None: + return Text('None') + + if skilldisplay == SkillDisplay.skillnames: + return Text(leader_skill.name_en) + return Text(leader_skill.desc) + + @classmethod + @abstractmethod + def embed(cls, state: ViewStateBaseId) -> EmbedView: + ... diff --git a/padinfo/view/components/evo_scroll_mixin.py b/padinfo/view/components/evo_scroll_mixin.py new file mode 100644 index 00000000..ef070a19 --- /dev/null +++ b/padinfo/view/components/evo_scroll_mixin.py @@ -0,0 +1,87 @@ +from abc import abstractmethod +from typing import List, NamedTuple, Optional, TYPE_CHECKING + +from discordmenu.embed.components import EmbedField +from discordmenu.embed.text import HighlightableLinks, LinkedText +from tsutils.query_settings.query_settings import QuerySettings +from tsutils.tsubaki.links import MonsterLink + +if TYPE_CHECKING: + from dbcog.models.monster_model import MonsterModel + from dbcog.models.evolution_model import EvolutionModel + + +class MonsterEvolution(NamedTuple): + monster: "MonsterModel" + evolution: Optional["EvolutionModel"] + + +class EvoScrollViewState: + alt_monster_ids: List[int] + monster: "MonsterModel" + alt_monsters: List["MonsterEvolution"] + query_settings: QuerySettings + + def decrement_monster(self, dbcog, ims: dict): + + # if this ever has a side effect in the actual class, it cannot remain as a mixin! + + index = self.alt_monster_ids.index(self.monster.monster_id) + prev_monster_id = self.alt_monster_ids[index - 1] + ims['resolved_monster_id'] = str(prev_monster_id) + + def increment_monster(self, dbcog, ims: dict): + index = self.alt_monster_ids.index(self.monster.monster_id) + if index == len(self.alt_monster_ids) - 1: + # cycle back to the beginning of the evos list + next_monster_id = self.alt_monster_ids[0] + else: + next_monster_id = self.alt_monster_ids[index + 1] + ims['resolved_monster_id'] = str(next_monster_id) + + @classmethod + def get_alt_monsters_and_evos(cls, dbcog, monster) -> List[MonsterEvolution]: + graph = dbcog.database.graph + alt_monsters = graph.get_alt_monsters(monster) + return [MonsterEvolution(m, graph.get_evolution(m)) for m in alt_monsters] + + @abstractmethod + def deserialize(self, dbcog, user_config, ims): + ... + + +class EvoScrollView: + @staticmethod + def evos_embed_field(state: EvoScrollViewState): + field_text = "**Evos**" + help_text = "" + # this isn't used right now, but maybe later if discord changes the api for embed titles...? + _help_link = "https://github.com/TsubakiBotPad/pad-cogs/wiki/Evolutions-mini-view" + legend_parts = [] + if any(not alt_evo.evolution.reversible for alt_evo in state.alt_monsters if alt_evo.evolution): + legend_parts.append("⌊Irreversible⌋") + if any(alt_evo.monster.is_equip for alt_evo in state.alt_monsters): + legend_parts.append("⌈Equip⌉") + if legend_parts: + help_text = ' – Help: {}'.format(" ".join(legend_parts)) + return EmbedField( + field_text + help_text, + HighlightableLinks( + links=[LinkedText( + EvoScrollView.alt_fmt(evo), + MonsterLink.header_link(evo.monster, state.query_settings) + ) for evo in state.alt_monsters], + highlighted=next(i for i, me in enumerate(state.alt_monsters) + if state.monster.monster_id == me.monster.monster_id) + ) + ) + + @staticmethod + def alt_fmt(evo: MonsterEvolution): + if evo.monster.is_equip: + fmt = "⌈{}⌉" + elif not evo.evolution or evo.evolution.reversible: + fmt = "{}" + else: + fmt = "⌊{}⌋" + return fmt.format(evo.monster.monster_no_na) diff --git a/padinfo/view/components/view_state_base_id.py b/padinfo/view/components/view_state_base_id.py new file mode 100644 index 00000000..ed38fadb --- /dev/null +++ b/padinfo/view/components/view_state_base_id.py @@ -0,0 +1,65 @@ +from typing import List, TYPE_CHECKING + +from discordmenu.embed.view_state import ViewState +from tsutils.menu.components.config import UserConfig +from tsutils.query_settings.enums import AltEvoSort +from tsutils.query_settings.query_settings import QuerySettings + +from padinfo.view.common import get_monster_from_ims +from padinfo.view.components.evo_scroll_mixin import EvoScrollViewState, MonsterEvolution + +if TYPE_CHECKING: + from dbcog.models.monster_model import MonsterModel + + +class ViewStateBaseId(ViewState, EvoScrollViewState): + def __init__(self, original_author_id, menu_type, raw_query, query, monster: "MonsterModel", + alt_monsters: List[MonsterEvolution], is_jp_buffed: bool, query_settings: QuerySettings, + reaction_list: List[str] = None, + extra_state=None): + super().__init__(original_author_id=original_author_id, menu_type=menu_type, raw_query=raw_query, + extra_state=extra_state) + self.dfs_alt_monsters = alt_monsters + self.reaction_list = reaction_list + self.monster = monster + self.is_jp_buffed = is_jp_buffed + self.query = query + self.query_settings = query_settings + + if self.query_settings.evosort == AltEvoSort.dfs: + self.alt_monsters = self.dfs_alt_monsters + else: + self.alt_monsters = sorted(self.dfs_alt_monsters, key=lambda m: m.monster.monster_id) + + self.alt_monster_ids = [m.monster.monster_id for m in self.alt_monsters] + + def serialize(self): + ret = super().serialize() + ret.update({ + 'query': self.query, + 'query_settings': self.query_settings.serialize(), + 'resolved_monster_id': self.monster.monster_id, + 'reaction_list': self.reaction_list, + }) + return ret + + @classmethod + async def deserialize(cls, dbcog, user_config: UserConfig, ims: dict): + if ims.get('unsupported_transition'): + return None + monster = await get_monster_from_ims(dbcog, ims) + + alt_monsters = cls.get_alt_monsters_and_evos(dbcog, monster) + + raw_query = ims['raw_query'] + query = ims.get('query') or raw_query + query_settings = QuerySettings.deserialize(ims.get('query_settings')) + original_author_id = ims['original_author_id'] + menu_type = ims['menu_type'] + reaction_list = ims.get('reaction_list') + is_jp_buffed = dbcog.database.graph.monster_is_discrepant(monster) + + return cls(original_author_id, menu_type, raw_query, query, monster, + alt_monsters, is_jp_buffed, query_settings, + reaction_list=reaction_list, + extra_state=ims) diff --git a/padinfo/view/droploc.py b/padinfo/view/droploc.py new file mode 100644 index 00000000..e3950982 --- /dev/null +++ b/padinfo/view/droploc.py @@ -0,0 +1,107 @@ +from typing import TYPE_CHECKING, List + +from discordmenu.embed.components import EmbedMain, EmbedThumbnail +from discordmenu.embed.view import EmbedView +from tsutils.menu.components.config import UserConfig +from tsutils.menu.components.footers import embed_footer_with_state +from tsutils.menu.view.view_state_base import ViewStateBase +from tsutils.query_settings.query_settings import QuerySettings +from tsutils.tsubaki.links import MonsterLink, MonsterImage +from tsutils.tsubaki.monster_header import MonsterHeader + +from padinfo.view.components.evo_scroll_mixin import EvoScrollView, EvoScrollViewState, MonsterEvolution + +if TYPE_CHECKING: + from dbcog.models.monster_model import MonsterModel + + +class DroplocQueriedProps: + def __init__(self, monster: "MonsterModel", alt_monsters: List[MonsterEvolution], + subdungeons, dungeons): + self.monster = monster + self.alt_monsters = alt_monsters + self.subdungeons = subdungeons + self.dungeons = dungeons + + +class DroplocViewState(ViewStateBase, EvoScrollViewState): + VIEW_STATE_TYPE = "Droploc" + + def __init__(self, original_author_id, menu_type, query, query_settings: QuerySettings, + monster_id: int, + queried_props: DroplocQueriedProps, + display: str = 'expand', + extra_state=None): + super().__init__(original_author_id, menu_type, query, + extra_state=extra_state) + self.monster_id = monster_id + self.queried_props = queried_props + self.monster = self.queried_props.monster + self.alt_monsters = self.queried_props.alt_monsters + self.display = display + self.query_settings = query_settings + + def serialize(self): + ret = super().serialize() + ret.update({ + 'monster_id': self.monster_id, + 'query_settings': self.query_settings.serialize(), + 'display': self.display, + }) + return ret + + @classmethod + async def deserialize(cls, dbcog, user_config: UserConfig, ims: dict): + original_author_id = ims['original_author_id'] + menu_type = ims['menu_type'] + raw_query = ims['raw_query'] + query = ims.get('query') or raw_query + query_settings = QuerySettings.deserialize(ims.get('query_settings')) + display = ims['display'] + monster_id = ims['monster_id'] + queried_props = await cls.query(monster_id, dbcog) + + return cls(original_author_id, menu_type, query, + query_settings, monster_id, queried_props, display) + + @staticmethod + async def query(monster_id, dbcog): + graph = dbcog.database.graph + monster = await graph.get_monster(monster_id) + alt_monsters = graph.get_alt_monsters(monster) + evolutions = [MonsterEvolution(m, graph.get_evolution(m)) for m in alt_monsters] + subdungeons = dbcog.database.dungeon.get_subdungeons_from_drop_monster(monster) + dungeons = dbcog.database.dungeon.get_dungeon_mapping(subdungeons) + props = DroplocQueriedProps(monster, evolutions, subdungeons, dungeons) + return props + + @staticmethod + def expand(ims): + ims['display'] = 'expand' + + @staticmethod + def contract(ims): + ims['display'] = 'contract' + + +class DroplocView(EvoScrollView): + VIEW_TYPE = "Droploc" + + @classmethod + def embed(cls, state: DroplocViewState): + m = state.queried_props.monster + + fields = [ + cls.evos_embed_field(state) + ] + + return EmbedView( + EmbedMain( + color=state.query_settings.embedcolor, + title=MonsterHeader.menu_title(m), + url=MonsterLink.header_link(m, state.query_settings) + ), + embed_thumbnail=EmbedThumbnail(MonsterImage.icon(m.monster_id)), + embed_footer=embed_footer_with_state(state), + embed_fields=fields + ) diff --git a/padinfo/view/dungeon_list/__init__.py b/padinfo/view/dungeon_list/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padinfo/view/dungeon_list/dungeon_list.py b/padinfo/view/dungeon_list/dungeon_list.py new file mode 100644 index 00000000..e8a6eb0f --- /dev/null +++ b/padinfo/view/dungeon_list/dungeon_list.py @@ -0,0 +1,68 @@ +from typing import List + +from discordmenu.embed.base import Box +from discordmenu.embed.components import EmbedMain, EmbedField +from discordmenu.embed.text import LinkedText +from discordmenu.embed.view import EmbedView +from tsutils.menu.components.footers import embed_footer_with_state +from tsutils.menu.view.closable_embed import ClosableEmbedViewState + + +class DungeonListViewProps: + def __init__(self, dungeons: List[dict]): + self.dungeons = dungeons + + +class DungeonListBase: + VIEW_TYPE: str + dungeon_link: str + subdungeon_link: str + bad_chars: list + + @classmethod + def embed_fields(cls, dungeons, props): + ret = [] + for dungeon in dungeons: + ret.append(cls.format_dg_link(dungeon, props)) + for subdungeon in dungeon['subdungeons']: + ret.append(Box( + "\u200b \u200b \u200b \u200b \u200b ", + cls.print_name(subdungeon, props), + delimiter='' + )) + return [EmbedField('Dungeons', Box(*ret, delimiter='\n'))] + + @classmethod + def format_dg_link(cls, dungeon, _props: DungeonListViewProps): + return LinkedText(cls.escape_name(dungeon['name_en']), + cls.dungeon_link.format(dungeon['idx'])) + + @classmethod + def print_name(cls, subdungeon, props: DungeonListViewProps): + return LinkedText(cls.escape_name(subdungeon['name_en']), + cls.format_sd_link(subdungeon, props)) + + @classmethod + def format_sd_link(cls, subdungeon, _props: DungeonListViewProps): + return cls.subdungeon_link.format(subdungeon['idx']) + + @classmethod + def escape_name(cls, name): + for char in cls.bad_chars: + name = name.replace(char, '') + return name + + @classmethod + def description(cls, _props: DungeonListViewProps): + return 'The following dungeons were found.' + + @classmethod + def embed(cls, state: ClosableEmbedViewState, props: DungeonListViewProps): + return EmbedView( + EmbedMain( + color=state.query_settings.embedcolor, + description=cls.description(props) + ), + embed_footer=embed_footer_with_state(state), + embed_fields=cls.embed_fields(props.dungeons, props) + ) diff --git a/padinfo/view/dungeon_list/jp_dungeon_name.py b/padinfo/view/dungeon_list/jp_dungeon_name.py new file mode 100644 index 00000000..e4271d72 --- /dev/null +++ b/padinfo/view/dungeon_list/jp_dungeon_name.py @@ -0,0 +1,20 @@ +from padinfo.view.dungeon_list.dungeon_list import DungeonListBase, DungeonListViewProps + + +class JpDungeonNameViewProps(DungeonListViewProps): + ... + + +class JpDungeonNameView(DungeonListBase): + VIEW_TYPE = 'JpDungeonName' + dungeon_link = '' + subdungeon_link = '' + bad_chars = [''] + + @classmethod + def format_dg_link(cls, dungeon, props): + return cls.escape_name(dungeon['name']) + + @classmethod + def print_name(cls, subdungeon, props: DungeonListViewProps): + return cls.escape_name(subdungeon['name']) diff --git a/padinfo/view/dungeon_list/jpytdglead.py b/padinfo/view/dungeon_list/jpytdglead.py new file mode 100644 index 00000000..3182ebba --- /dev/null +++ b/padinfo/view/dungeon_list/jpytdglead.py @@ -0,0 +1,49 @@ +from typing import TYPE_CHECKING + +from discordmenu.embed.text import LinkedText +from tsutils.tsubaki.monster_header import MonsterHeader + +from padinfo.view.dungeon_list.dungeon_list import DungeonListBase, DungeonListViewProps + +if TYPE_CHECKING: + from dbcog.models.monster_model import MonsterModel + + +class JpYtDgLeadProps(DungeonListViewProps): + def __init__(self, dungeons, monster: "MonsterModel"): + super().__init__(dungeons) + self.monster = monster + + +class JpYtDgLeadView(DungeonListBase): + VIEW_TYPE = 'JpYtDgLead' + dungeon_link = 'https://www.youtube.com/results?search_query={}%20{}' + subdungeon_link = 'https://www.youtube.com/results?search_query={}%20{}' + bad_chars = ['-'] + whitespace = [' ', ' '] + + @classmethod + def format_dg_link(cls, dungeon, props: JpYtDgLeadProps): + link = cls.dungeon_link.format(cls.escape_name(dungeon['name']), cls.escape_name(props.monster.name_ja)) + return LinkedText(cls.escape_name(dungeon['name_en']), + cls.escape_whitespace(link)) + + @classmethod + def format_sd_link(cls, subdungeon, props: JpYtDgLeadProps): + return cls.escape_whitespace(( + cls.subdungeon_link.format(cls.escape_name(subdungeon['name']), cls.escape_name(props.monster.name_ja)))) + + @classmethod + def print_name(cls, subdungeon, props: JpYtDgLeadProps): + return LinkedText(cls.escape_name(subdungeon['name_en']), + cls.format_sd_link(subdungeon, props)) + + @classmethod + def description(cls, props: JpYtDgLeadProps): + return MonsterHeader.text_with_emoji(props.monster) + + @classmethod + def escape_whitespace(cls, link): + for item in cls.whitespace: + link = link.replace(item, '%20') + return link diff --git a/padinfo/view/dungeon_list/skyo_links.py b/padinfo/view/dungeon_list/skyo_links.py new file mode 100644 index 00000000..f952a7ab --- /dev/null +++ b/padinfo/view/dungeon_list/skyo_links.py @@ -0,0 +1,12 @@ +from padinfo.view.dungeon_list.dungeon_list import DungeonListBase, DungeonListViewProps + + +class SkyoLinksViewProps(DungeonListViewProps): + ... + + +class SkyoLinksView(DungeonListBase): + VIEW_TYPE = 'SkyoLinks' + dungeon_link = 'https://skyo.tsubakibot.com/{}' + subdungeon_link = 'https://skyo.tsubakibot.com/{}' + bad_chars = ['[', ']'] diff --git a/padinfo/view/evos.py b/padinfo/view/evos.py new file mode 100644 index 00000000..0fe91d7c --- /dev/null +++ b/padinfo/view/evos.py @@ -0,0 +1,111 @@ +from typing import List, TYPE_CHECKING + +from discordmenu.embed.base import Box +from discordmenu.embed.components import EmbedField, EmbedMain, EmbedThumbnail +from discordmenu.embed.view import EmbedView +from tsutils.menu.components.config import UserConfig +from tsutils.menu.components.footers import embed_footer_with_state +from tsutils.query_settings.query_settings import QuerySettings +from tsutils.tsubaki.links import MonsterImage, MonsterLink +from tsutils.tsubaki.monster_header import MonsterHeader + +from padinfo.view.base import BaseIdView +from padinfo.view.common import get_monster_from_ims +from padinfo.view.components.evo_scroll_mixin import MonsterEvolution +from padinfo.view.components.view_state_base_id import ViewStateBaseId + +if TYPE_CHECKING: + from dbcog.models.monster_model import MonsterModel + + +class EvosViewState(ViewStateBaseId): + def __init__(self, original_author_id, menu_type, raw_query, query, monster: "MonsterModel", + alt_monsters: List[MonsterEvolution], is_jp_buffed: bool, query_settings: QuerySettings, + alt_versions: List["MonsterModel"], gem_versions: List["MonsterModel"], + reaction_list: List[str] = None, + extra_state=None): + super().__init__(original_author_id, menu_type, raw_query, query, monster, + alt_monsters, is_jp_buffed, query_settings, + reaction_list=reaction_list, + extra_state=extra_state) + self.alt_versions = alt_versions + self.gem_versions = gem_versions + + def serialize(self): + ret = super().serialize() + ret.update({ + 'pane_type': EvosView.VIEW_TYPE, + }) + return ret + + @classmethod + async def deserialize(cls, dbcog, user_config: UserConfig, ims: dict): + if ims.get('unsupported_transition'): + return None + monster = await get_monster_from_ims(dbcog, ims) + alt_versions, gem_versions = await EvosViewState.do_query(dbcog, monster) + + if alt_versions is None: + return None + alt_monsters = cls.get_alt_monsters_and_evos(dbcog, monster) + raw_query = ims['raw_query'] + query = ims.get('query') or raw_query + query_settings = QuerySettings.deserialize(ims.get('query_settings')) + original_author_id = ims['original_author_id'] + menu_type = ims['menu_type'] + reaction_list = ims.get('reaction_list') + is_jp_buffed = dbcog.database.graph.monster_is_discrepant(monster) + + return cls(original_author_id, menu_type, raw_query, query, monster, + alt_monsters, is_jp_buffed, query_settings, + alt_versions, gem_versions, + reaction_list=reaction_list, + extra_state=ims) + + @staticmethod + async def do_query(dbcog, monster): + db_context = dbcog.database + alt_versions = sorted(db_context.graph.get_alt_monsters(monster), + key=lambda x: x.monster_id) + gem_versions = list(filter(None, map(db_context.graph.evo_gem_monster, alt_versions))) + if len(alt_versions) == 1 and len(gem_versions) == 0: + return None, None + return alt_versions, gem_versions + + +class EvosView(BaseIdView): + VIEW_TYPE = 'Evos' + + @staticmethod + def _evo_lines(monsters, current_monster, query_settings): + if not len(monsters): + return [] + return [ + MonsterHeader.box_with_emoji(ae, link=ae.monster_id != current_monster.monster_id, query_settings=query_settings) + for ae in sorted(monsters, key=lambda x: int(x.monster_id)) + ] + + @classmethod + def embed(cls, state: EvosViewState): + fields = [ + EmbedField( + ("{} evolution" if len(state.alt_versions) == 1 else "{} evolutions").format(len(state.alt_versions)), + Box(*EvosView._evo_lines(state.alt_versions, state.monster, state.query_settings)))] + + if state.gem_versions: + fields.append( + EmbedField( + ("{} evolve gem" if len(state.gem_versions) == 1 else "{} evolve gems").format( + len(state.gem_versions)), + Box(*EvosView._evo_lines(state.gem_versions, state.monster)))) + + return EmbedView( + EmbedMain( + color=state.query_settings.embedcolor, + title=MonsterHeader.menu_title(state.monster, + is_tsubaki=state.alt_monsters[0].monster.monster_id == cls.TSUBAKI, + is_jp_buffed=state.is_jp_buffed).to_markdown(), + url=MonsterLink.header_link(state.monster, state.query_settings)), + embed_thumbnail=EmbedThumbnail(MonsterImage.icon(state.monster.monster_id)), + embed_footer=embed_footer_with_state(state), + embed_fields=fields) diff --git a/padinfo/view/experience_curve.py b/padinfo/view/experience_curve.py new file mode 100644 index 00000000..d26b3155 --- /dev/null +++ b/padinfo/view/experience_curve.py @@ -0,0 +1,164 @@ +from math import ceil +from typing import TYPE_CHECKING, Text, Union + +from discordmenu.embed.base import Box +from discordmenu.embed.components import EmbedField, EmbedMain, EmbedThumbnail +from discordmenu.embed.text import LabeledText +from discordmenu.embed.view import EmbedView +from discordmenu.emoji.emoji_cache import emoji_cache +from redbot.core.utils.chat_formatting import humanize_number +from tsutils.menu.components.footers import embed_footer_with_state +from tsutils.tsubaki.links import MonsterImage, MonsterLink +from tsutils.tsubaki.monster_header import MonsterHeader + +if TYPE_CHECKING: + from dbcog.models.monster_model import MonsterModel + +SSG_EXP = 4_000_000 +GLOBE_EXP = { + 0: 2_600_000, + 1: 2_900_000, + 2: 3_250_000, + 3: 3_000_000, + 4: 3_000_000, +} +TA2_EXP = 12_000_000 +TA3_EXP = 50_000_000 + + +class ExperienceCurveViewProps: + def __init__(self, monster: "MonsterModel", low: int, high: int, offset: Union[float, int]): + self.monster = monster + self.low = low + self.high = high + + if offset <= 1: + if low <= 99: + self.offset = int((monster.exp_to_level(low + 1) - monster.exp_to_level(low)) * offset) + elif low <= 110: + self.offset = int(5e6 * offset) + else: + self.offset = int(20e6 * offset) + else: + self.offset = offset + + +def get_normal_exp_difference(monster: "MonsterModel", low: int, high: int, offset: int) -> int: + if low >= 99: + return 0 + total_low = monster.exp_to_level(low) + total_high = monster.exp_to_level(min(high, 99)) + total = int(total_high - total_low) + if low < 99: + return total - offset + return total + + +def get_lb_exp_difference(monster: "MonsterModel", low: int, high: int, offset: int) -> int: + if high <= 99 or low >= 110: + return 0 + total = int(5e6 * (min(high, 110) - max(low, 100))) + if 99 <= low < 110: + return total - offset + return total + + +def get_slb_exp_difference(monster: "MonsterModel", low: int, high: int, offset: int) -> int: + if high <= 110: + return 0 + total = int(20e6 * (high - max(low, 110))) + if 110 <= low < 120: + return total - offset + return total + + +def trunc_humanize(n: int) -> str: + def strip_float(f) -> str: + return str(float(f)).rstrip('0').rstrip('.') + + if n < 1e6: + return strip_float(round(n, 1)) + if n < 1e9: + return strip_float(round(n / 1e6, 1)) + 'm' + if n < 1e12: + return strip_float(round(n / 1e9, 1)) + 'b' + return str(n) + + +def get_total_needed(monster: "MonsterModel", low: int, high: int, offset: int, amount: int, is_light: bool) -> int: + ssg = SSG_EXP + if is_light: + ssg *= 1.5 + + total = ceil(get_normal_exp_difference(monster, low, high, offset) / amount) + + lb = get_lb_exp_difference(monster, low, high, offset) + if low <= 99: + lb -= ssg + total += ceil(max(0, lb) / amount) + + slb = get_slb_exp_difference(monster, low, high, offset) + if low <= 110: + slb -= ssg * 5 + total += ceil(max(0, slb) / amount) + + return int(total) + + +class ExperienceCurveView: + VIEW_TYPE = 'ExperienceCurve' + + @staticmethod + def embed(state, props: ExperienceCurveViewProps): + regular = get_normal_exp_difference(props.monster, props.low, props.high, props.offset) + lb = get_lb_exp_difference(props.monster, props.low, props.high, props.offset) + slb = get_slb_exp_difference(props.monster, props.low, props.high, props.offset) + is_light = props.monster.full_damage_attr.value == 3 + return EmbedView( + EmbedMain( + color=state.query_settings.embedcolor, + title=MonsterHeader.menu_title(props.monster, use_emoji=True), + url=MonsterLink.header_link(props.monster, query_settings=state.query_settings), + description=Text(f'lv{props.low} -> lv{props.high} (' + + (trunc_humanize(props.monster.exp_curve) if props.monster.exp_curve else "no") + + f' curve)'), + ), + embed_thumbnail=EmbedThumbnail(MonsterImage.icon(props.monster.monster_id)), + embed_fields=[ + EmbedField( + title='Exact', + body=Box( + LabeledText("Reg", humanize_number(regular)), + LabeledText("LB", humanize_number(lb)), + LabeledText("SLB", humanize_number(slb)), + LabeledText("Net", humanize_number(regular + lb + slb)) + ), + inline=True + ), + EmbedField( + title='Approx', + body=Box( + LabeledText("Reg", trunc_humanize(regular)), + LabeledText("LB", trunc_humanize(lb)), + LabeledText("SLB", trunc_humanize(slb)), + LabeledText("Net", trunc_humanize(regular + lb + slb)) + ), + inline=True + ), + EmbedField( + title='Resources', + body=Box( + LabeledText("Globes " + + emoji_cache.get_emoji(f'orb_{props.monster.full_damage_attr.name.lower()}'), + str(get_total_needed(props.monster, props.low, props.high, props.offset, + ceil(1.5 * GLOBE_EXP[props.monster.full_damage_attr.value]), + is_light))), + LabeledText("TA2", str(get_total_needed(props.monster, props.low, props.high, props.offset, + TA2_EXP, is_light))), + LabeledText("TA3", str(get_total_needed(props.monster, props.low, props.high, props.offset, + TA3_EXP, is_light))), + ), + inline=True + ) + ], + embed_footer=embed_footer_with_state(state)) diff --git a/padinfo/view/id.py b/padinfo/view/id.py new file mode 100644 index 00000000..2bed0c6d --- /dev/null +++ b/padinfo/view/id.py @@ -0,0 +1,302 @@ +from typing import Dict, List, Optional, TYPE_CHECKING + +from discordmenu.embed.base import Box +from discordmenu.embed.components import EmbedField, EmbedMain, EmbedThumbnail +from discordmenu.embed.text import BoldText, LabeledText, Text +from discordmenu.embed.view import EmbedView +from tsutils.enums import Server +from tsutils.menu.components.config import UserConfig +from tsutils.menu.components.footers import embed_footer_with_state +from tsutils.query_settings.enums import CardLevelModifier, CardModeModifier, CardPlusModifier +from tsutils.query_settings.query_settings import QuerySettings +from tsutils.tsubaki.custom_emoji import get_awakening_emoji, get_emoji +from tsutils.tsubaki.links import MonsterImage, MonsterLink +from tsutils.tsubaki.monster_header import MonsterHeader + +from padinfo.view.common import get_monster_from_ims, invalid_monster_text +from padinfo.view.components.base_id_main_view import BaseIdMainView +from padinfo.view.components.evo_scroll_mixin import EvoScrollView, MonsterEvolution +from padinfo.view.components.view_state_base_id import ViewStateBaseId + +if TYPE_CHECKING: + from dbcog.models.monster_model import MonsterModel + from dbcog.models.awakening_model import AwakeningModel + from dbcog.models.awoken_skill_model import AwokenSkillModel + from dbcog.database_context import DbContext + + +class IdQueriedProps: + def __init__(self, acquire_raw, base_rarity, transform_base, true_evo_type_raw, previous_evolutions, + previous_transforms, awoken_skill_map: Dict[int, "AwokenSkillModel"]): + self.previous_evolutions = previous_evolutions + self.true_evo_type_raw = true_evo_type_raw + self.transform_base = transform_base + self.previous_transforms = previous_transforms + self.base_rarity = base_rarity + self.acquire_raw = acquire_raw + self.awoken_skill_map = awoken_skill_map + + +class IdViewState(ViewStateBaseId): + nadiff_na_only_text = ', which is only in NA' + nadiff_jp_only_text = ', which is only in JP' + nadiff_identical_text = ', which is the same in NA & JP' + + def __init__(self, original_author_id, menu_type, raw_query, query, monster: "MonsterModel", + alt_monsters: List[MonsterEvolution], is_jp_buffed: bool, query_settings: QuerySettings, + id_queried_props: IdQueriedProps, + fallback_message: str = None, reaction_list: List[str] = None, + is_child: bool = False, extra_state=None): + super().__init__(original_author_id, menu_type, raw_query, query, monster, + alt_monsters, is_jp_buffed, query_settings, + reaction_list=reaction_list, + extra_state=extra_state) + self.fallback_message = fallback_message + self.is_child = is_child + self.acquire_raw = id_queried_props.acquire_raw + self.base_rarity = id_queried_props.base_rarity + self.previous_evolutions = id_queried_props.previous_evolutions + self.previous_transforms = id_queried_props.previous_transforms + self.transform_base: "MonsterModel" = id_queried_props.transform_base + self.true_evo_type_raw = id_queried_props.true_evo_type_raw + self.awoken_skill_map = id_queried_props.awoken_skill_map + + def serialize(self): + ret = super().serialize() + ret.update({ + 'pane_type': IdView.VIEW_TYPE, + 'is_child': self.is_child, + 'message': self.fallback_message, + }) + return ret + + @classmethod + async def deserialize(cls, dbcog, user_config: UserConfig, ims: dict): + # for numberscroll getting to a gap in monster book, or 1, or last monster + if ims.get('unsupported_transition'): + return None + monster = await get_monster_from_ims(dbcog, ims) + alt_monsters = cls.get_alt_monsters_and_evos(dbcog, monster) + id_queried_props = await IdViewState.do_query(dbcog, monster) + + raw_query = ims['raw_query'] + # This is to support the 2 vs 1 monster query difference between ^ls and ^id + query = ims.get('query') or raw_query + query_settings = QuerySettings.deserialize(ims.get('query_settings')) + menu_type = ims['menu_type'] + original_author_id = ims['original_author_id'] + reaction_list = ims.get('reaction_list') + fallback_message = ims.get('message') + is_child = ims.get('is_child') + is_jp_buffed = dbcog.database.graph.monster_is_discrepant(monster) + + return cls(original_author_id, menu_type, raw_query, query, monster, + alt_monsters, is_jp_buffed, query_settings, + id_queried_props, + fallback_message=fallback_message, + reaction_list=reaction_list, + is_child=is_child, + extra_state=ims) + + async def set_server(self, dbcog, server: Server): + self.query_settings.server = server + self.monster = dbcog.database.graph.get_monster(self.monster.monster_id, server=server) + self.alt_monsters = self.get_alt_monsters_and_evos(dbcog, self.monster) + id_queried_props = await self.do_query(dbcog, self.monster) + self.transform_base = id_queried_props.transform_base + self.true_evo_type_raw = id_queried_props.true_evo_type_raw + self.acquire_raw = id_queried_props.acquire_raw + self.base_rarity = id_queried_props.base_rarity + + @classmethod + async def do_query(cls, dbcog, monster) -> IdQueriedProps: + db_context = dbcog.database + id_queried_props = await IdViewState._get_monster_misc_info(db_context, monster) + return id_queried_props + + @classmethod + async def _get_monster_misc_info(cls, db_context: "DbContext", monster) -> IdQueriedProps: + transform_base = db_context.graph.get_transform_base(monster) + true_evo_type_raw = db_context.graph.true_evo_type(monster).value + acquire_raw = db_context.graph.monster_acquisition(monster) + base_rarity = db_context.graph.get_base_monster(monster).rarity + previous_evolutions = db_context.graph.get_all_prev_evolutions(monster, include_self=True) + previous_transforms = db_context.graph.get_all_prev_transforms(monster, include_self=False) + awoken_skill_map = db_context.awoken_skill_map + return IdQueriedProps(acquire_raw, base_rarity, transform_base, true_evo_type_raw, previous_evolutions, + previous_transforms, awoken_skill_map) + + def set_na_diff_invalid_message(self, ims: dict) -> bool: + message = self.get_na_diff_invalid_message() + if message is not None: + ims['message'] = message + return True + return False + + def get_na_diff_invalid_message(self) -> Optional[str]: + monster: "MonsterModel" = self.monster + if monster.on_na and not monster.on_jp: + return invalid_monster_text(self.query, monster, self.nadiff_na_only_text) + if monster.on_jp and not monster.on_na: + return invalid_monster_text(self.query, monster, self.nadiff_jp_only_text) + if not self.is_jp_buffed: + return invalid_monster_text(self.query, monster, self.nadiff_identical_text) + return None + + +def _get_awakening_text(awakening: "AwakeningModel"): + return get_awakening_emoji(awakening.awoken_skill_id, awakening.name) + + +def _get_awakening_emoji_for_stats(m: "MonsterModel", i: int): + return get_awakening_emoji(i) if m.awakening_count(i) and not m.is_equip else '' + + +def _get_stat_text(stat, lb_stat, icon): + return Box( + Text(str(stat)), + Text("({})".format(lb_stat)) if lb_stat else None, + Text(icon) if icon else None, + delimiter=' ' + ) + + +def _monster_is_enhance(m: "MonsterModel"): + return any(x if x.name == 'Enhance' else None for x in m.types) + + +class IdView(BaseIdMainView, EvoScrollView): + VIEW_TYPE = 'Id' + + @classmethod + def all_awakenings_row(cls, m: "MonsterModel", transform_base): + if len(m.awakenings) == 0: + return Box(Text('No Awakenings')) + + return Box( + Box( + get_emoji(cls.up_emoji_name) if m != transform_base else '', + cls.normal_awakenings_row(m), + delimiter=' ' + ), + Box( + '\N{DOWN-POINTING RED TRIANGLE}', + IdView.all_awakenings_row(transform_base, transform_base), + delimiter=' ' + ) if m != transform_base else None, + cls.super_awakenings_row(m), + ) + + @staticmethod + def misc_info(m: "MonsterModel", true_evo_type_raw: str, acquire_raw: str, base_rarity: str): + rarity = Box( + LabeledText('Rarity', str(m.rarity)), + Text('({})'.format(LabeledText('Base', str(base_rarity)).to_markdown())), + Text("" if m.orb_skin_id is None else "(Orb Skin)"), + Text("" if m.bgm_id is None else "(BGM)"), + delimiter=' ' + ) + + cost = LabeledText('Cost', str(m.cost)) + acquire = BoldText(acquire_raw) if acquire_raw else None + series = BoldText(m.series.name_en) if m.series else None + valid_true_evo_types = ("Reincarnated", "Assist", "Pixel", "Super Reincarnated") + true_evo_type = BoldText(true_evo_type_raw) if true_evo_type_raw in valid_true_evo_types else None + + return Box(rarity, cost, series, acquire, true_evo_type) + + @classmethod + def stats(cls, m: "MonsterModel", previous_evolutions, query_settings: QuerySettings): + plus = cls.get_plus_status(previous_evolutions, query_settings.cardplus) + multiplayer = query_settings.cardmode == CardModeModifier.coop + lb_level = 110 if query_settings.cardlevel == CardLevelModifier.lv110 else 120 + hp, atk, rcv, weighted = m.stats(plus=plus, multiplayer=multiplayer) + + lb_hp, lb_atk, lb_rcv, lb_weighted = (None, None, None, None) + if m.limit_mult > 0: + lb_hp, lb_atk, lb_rcv, lb_weighted = m.stats(plus=plus, lv=lb_level, multiplayer=multiplayer) + + return Box( + LabeledText('HP', _get_stat_text(hp, lb_hp, _get_awakening_emoji_for_stats(m, 1))), + LabeledText('ATK', _get_stat_text(atk, lb_atk, _get_awakening_emoji_for_stats(m, 2))), + LabeledText('RCV', _get_stat_text(rcv, lb_rcv, _get_awakening_emoji_for_stats(m, 3))), + LabeledText('Fodder EXP', "{:,}".format(m.fodder_exp)) if _monster_is_enhance(m) else None + ) + + @staticmethod + def get_plus_status(previous_evolutions: List["MonsterModel"], cardplus: CardPlusModifier): + if cardplus == CardPlusModifier.plus0: + return 0 + if all([m.level == 1 and m.is_material for m in previous_evolutions]): + return 0 + return 297 if cardplus == CardPlusModifier.plus297 else 0 + + @classmethod + def stats_header(cls, m: "MonsterModel", previous_evolutions, query_settings: QuerySettings): + voice_emoji = get_awakening_emoji(63) if m.awakening_count(63) and not m.is_equip else '' + + multiboost_emoji = None + if m.awakening_count(30) and query_settings.cardmode == CardModeModifier.coop: + multiboost_emoji = get_emoji('misc_multiboost') + + plus_emoji = get_emoji('plus_297') + if cls.get_plus_status(previous_evolutions, query_settings.cardplus) != 297: + plus_emoji = get_emoji('plus_0') + + lb_emoji = get_emoji('lv110') + if m.limit_mult > 0 and query_settings.cardlevel == CardLevelModifier.lv120: + lb_emoji = get_emoji('lv120') + + header = Box( + Text(voice_emoji), + Text(plus_emoji), + Text(multiboost_emoji) if multiboost_emoji else None, + Text('Stats'), + Text('({}, +{}%)'.format(lb_emoji, m.limit_mult)) if m.limit_mult else None, + delimiter=' ' + ) + return header + + @classmethod + def embed(cls, state: IdViewState): + m = state.monster + qs = state.query_settings + fields = [ + EmbedField( + '/'.join(['{}'.format(t.name) for t in m.types]), + Box( + IdView.all_awakenings_row(m, state.transform_base), + cls.killers_row(m, state.transform_base) + ) + ), + EmbedField( + 'Inheritable' if m.is_inheritable else 'Not inheritable', + IdView.misc_info(m, state.true_evo_type_raw, state.acquire_raw, state.base_rarity), + inline=True + ), + EmbedField( + IdView.stats_header(m, state.previous_evolutions, qs).to_markdown(), + IdView.stats(m, state.previous_evolutions, qs), + inline=True + ), + EmbedField( + cls.active_skill_header(m, state.previous_transforms).to_markdown(), + Text(cls.active_skill_text(m.active_skill, state.awoken_skill_map, qs.skilldisplay)) + ), + EmbedField( + cls.leader_skill_header(m, qs.lsmultiplier, state.transform_base).to_markdown(), + cls.leader_skill_text(m.leader_skill, qs.skilldisplay) + ), + cls.evos_embed_field(state) + ] + + return EmbedView( + EmbedMain( + color=qs.embedcolor, + title=MonsterHeader.menu_title(m, + is_tsubaki=state.alt_monsters[0].monster.monster_id == cls.TSUBAKI, + is_jp_buffed=state.is_jp_buffed).to_markdown(), + url=MonsterLink.header_link(m, qs)), + embed_thumbnail=EmbedThumbnail(MonsterImage.icon(m.monster_id)), + embed_footer=embed_footer_with_state(state), + embed_fields=fields) diff --git a/padinfo/view/id_traceback.py b/padinfo/view/id_traceback.py new file mode 100644 index 00000000..33271b8c --- /dev/null +++ b/padinfo/view/id_traceback.py @@ -0,0 +1,42 @@ +from typing import TYPE_CHECKING + +from discordmenu.embed.base import Box +from discordmenu.embed.components import EmbedField, EmbedMain +from discordmenu.embed.text import LabeledText +from discordmenu.embed.view import EmbedView +from tsutils.menu.components.footers import embed_footer_with_state +from tsutils.tsubaki.custom_emoji import get_attribute_emoji_by_monster +from tsutils.tsubaki.monster_header import MonsterHeader + +if TYPE_CHECKING: + from dbcog.models.monster_model import MonsterModel + + +class IdTracebackViewProps: + def __init__(self, monster: "MonsterModel", score: float, name_tokens: str, + modifier_tokens: str, lower_priority_monsters: str): + self.lower_priority_monsters = lower_priority_monsters + self.modifier_tokens = modifier_tokens + self.name_tokens = name_tokens + self.score = score + self.monster = monster + + +class IdTracebackView: + VIEW_TYPE = 'IdTraceback' + + @staticmethod + def embed(state, props: IdTracebackViewProps): + return EmbedView( + EmbedMain( + color=state.query_settings.embedcolor, + title=MonsterHeader.menu_title(props.monster, use_emoji=True), + description=Box(LabeledText('Total score', str(round(props.score, 2)))) + ), + embed_fields=[ + EmbedField('Matched Name Tokens', Box(props.name_tokens)), + EmbedField('Matched Modifier Tokens', Box(props.modifier_tokens)), + EmbedField('Equally-scoring matches', Box(props.lower_priority_monsters)), + ], + embed_footer=embed_footer_with_state(state), + ) diff --git a/padinfo/view/leader_skill.py b/padinfo/view/leader_skill.py new file mode 100644 index 00000000..9d67041a --- /dev/null +++ b/padinfo/view/leader_skill.py @@ -0,0 +1,69 @@ +from typing import TYPE_CHECKING + +from discordmenu.embed.base import Box +from discordmenu.embed.components import EmbedMain +from discordmenu.embed.text import BoldText, Text +from discordmenu.embed.view import EmbedView +from tsutils.menu.components.config import UserConfig +from tsutils.menu.components.footers import embed_footer_with_state +from tsutils.menu.view.view_state_base import ViewStateBase +from tsutils.query_settings.query_settings import QuerySettings +from tsutils.tsubaki.monster_header import MonsterHeader + +from padinfo.core.leader_skills import leaderskill_query, ls_multiplier_text + +if TYPE_CHECKING: + pass + + +class LeaderSkillViewState(ViewStateBase): + def __init__(self, original_author_id, menu_type, raw_query, l_mon, r_mon, l_query, r_query, + l_query_settings, r_query_settings): + super().__init__(original_author_id, menu_type, raw_query, extra_state=None) + self.l_mon = l_mon + self.l_query = l_query + self.l_query_settings = l_query_settings + self.r_mon = r_mon + self.r_query = r_query + self.r_query_settings = r_query_settings + + def serialize(self): + ret = super().serialize() + ret.update({ + 'l_query': self.l_query, + 'r_query': self.r_query, + 'l_query_settings': self.l_query_settings.serialize(), + 'r_query_settings': self.r_query_settings.serialize() + }) + return ret + + @staticmethod + async def deserialize(dbcog, user_config: UserConfig, ims: dict): + raw_query = ims['raw_query'] + original_author_id = ims['original_author_id'] + menu_type = ims['menu_type'] + l_query_settings = QuerySettings.deserialize(ims['l_query_settings']) + r_query_settings = QuerySettings.deserialize(ims['r_query_settings']) + + l_mon, l_query, r_mon, r_query = await leaderskill_query(dbcog, raw_query, ims['original_author_id']) + return LeaderSkillViewState(original_author_id, menu_type, raw_query, l_mon, r_mon, l_query, + r_query, l_query_settings, r_query_settings) + + +class LeaderSkillView: + VIEW_TYPE = 'LeaderSkill' + + @staticmethod + def embed(state: LeaderSkillViewState): + lls = state.l_mon.leader_skill + rls = state.r_mon.leader_skill + return EmbedView( + embed_main=EmbedMain( + title=ls_multiplier_text(lls, rls), + description=Box( + BoldText(MonsterHeader.box_with_emoji(state.l_mon, query_settings=state.l_query_settings)), + Text(lls.desc if lls else 'None'), + BoldText(MonsterHeader.box_with_emoji(state.r_mon, query_settings=state.r_query_settings)), + Text(rls.desc if rls else 'None')), + color=state.l_query_settings.embedcolor), + embed_footer=embed_footer_with_state(state)) diff --git a/padinfo/view/leader_skill_single.py b/padinfo/view/leader_skill_single.py new file mode 100644 index 00000000..9af09ae4 --- /dev/null +++ b/padinfo/view/leader_skill_single.py @@ -0,0 +1,59 @@ +from typing import TYPE_CHECKING + +from discordmenu.embed.base import Box +from discordmenu.embed.components import EmbedMain +from discordmenu.embed.text import BoldText, Text +from discordmenu.embed.view import EmbedView +from tsutils.menu.components.config import UserConfig +from tsutils.menu.components.footers import embed_footer_with_state +from tsutils.menu.view.view_state_base import ViewStateBase +from tsutils.query_settings.query_settings import QuerySettings +from tsutils.tsubaki.monster_header import MonsterHeader + +from padinfo.core.leader_skills import ls_single_multiplier_text +from padinfo.view.common import get_monster_from_ims + +if TYPE_CHECKING: + pass + + +class LeaderSkillSingleViewState(ViewStateBase): + def __init__(self, original_author_id, menu_type, raw_query, query_settings, mon): + super().__init__(original_author_id, menu_type, raw_query, extra_state=None) + self.mon = mon + self.query_settings = query_settings + + def serialize(self): + ret = super().serialize() + ret.update({ + 'query_settings': self.query_settings.serialize() + }) + return ret + + @staticmethod + async def deserialize(dbcog, user_config: UserConfig, ims: dict): + raw_query = ims['raw_query'] + original_author_id = ims['original_author_id'] + menu_type = ims['menu_type'] + query_settings = QuerySettings.deserialize(ims['query_settings']) + + mon = await get_monster_from_ims(dbcog, ims) + return LeaderSkillSingleViewState(original_author_id, menu_type, + raw_query, query_settings, + mon) + + +class LeaderSkillSingleView: + VIEW_TYPE = 'LeaderSkillSingle' + + @staticmethod + def embed(state: LeaderSkillSingleViewState): + ls = state.mon.leader_skill + return EmbedView( + embed_main=EmbedMain( + title=ls_single_multiplier_text(ls), + description=Box( + BoldText(MonsterHeader.box_with_emoji(state.mon, query_settings=state.query_settings)), + Text(ls.desc if ls else 'None')), + color=state.query_settings.embedcolor), + embed_footer=embed_footer_with_state(state)) diff --git a/padinfo/view/links.py b/padinfo/view/links.py new file mode 100644 index 00000000..6ba57673 --- /dev/null +++ b/padinfo/view/links.py @@ -0,0 +1,35 @@ +from typing import TYPE_CHECKING + +from discordmenu.embed.base import Box +from discordmenu.embed.components import EmbedMain, EmbedThumbnail +from discordmenu.embed.text import LinkedText +from discordmenu.embed.view import EmbedView +from tsutils.query_settings.query_settings import QuerySettings +from tsutils.tsubaki.links import MonsterImage, MonsterLink +from tsutils.tsubaki.monster_header import MonsterHeader + +if TYPE_CHECKING: + from dbcog.models.monster_model import MonsterModel + + +class LinksView: + VIEW_TYPE = 'Links' + + @staticmethod + def linksbox(m): + return Box( + LinkedText('YouTube', MonsterLink.youtube_search(m)), + LinkedText('Skyozora', MonsterLink.skyozora(m)), + LinkedText('PADIndex', MonsterLink.padindex(m)), + LinkedText('Ilmina', MonsterLink.ilmina(m)), + delimiter=' | ') + + @staticmethod + def embed(m: "MonsterModel", query_settings: QuerySettings): + return EmbedView( + EmbedMain( + color=query_settings.embedcolor, + title=MonsterHeader.menu_title(m).to_markdown(), + description=LinksView.linksbox(m), + url=MonsterLink.header_link(m, query_settings)), + embed_thumbnail=EmbedThumbnail(MonsterImage.icon(m.monster_id))) diff --git a/padinfo/view/lookup.py b/padinfo/view/lookup.py new file mode 100644 index 00000000..bc7c12d4 --- /dev/null +++ b/padinfo/view/lookup.py @@ -0,0 +1,23 @@ +from typing import TYPE_CHECKING + +from discordmenu.embed.components import EmbedAuthor, EmbedMain +from discordmenu.embed.view import EmbedView +from tsutils.query_settings.query_settings import QuerySettings +from tsutils.tsubaki.links import MonsterImage, MonsterLink +from tsutils.tsubaki.monster_header import MonsterHeader + +if TYPE_CHECKING: + from dbcog.models.monster_model import MonsterModel + + +class LookupView: + VIEW_TYPE = 'Lookup' + + @staticmethod + def embed(m: "MonsterModel", query_settings: QuerySettings): + return EmbedView( + EmbedMain(color=query_settings.embedcolor), + embed_author=EmbedAuthor( + MonsterHeader.menu_title(m).to_markdown(), + MonsterLink.header_link(m, query_settings), + MonsterImage.icon(m.monster_id))) diff --git a/padinfo/view/materials.py b/padinfo/view/materials.py new file mode 100644 index 00000000..ac8cb061 --- /dev/null +++ b/padinfo/view/materials.py @@ -0,0 +1,178 @@ +from typing import List, Optional, TYPE_CHECKING + +from discordmenu.embed.base import Box +from discordmenu.embed.components import EmbedField, EmbedMain, EmbedThumbnail +from discordmenu.embed.text import LinkedText +from discordmenu.embed.view import EmbedView +from tsutils.menu.components.config import UserConfig +from tsutils.menu.components.footers import embed_footer_with_state +from tsutils.query_settings.query_settings import QuerySettings +from tsutils.tsubaki.links import MonsterImage, MonsterLink +from tsutils.tsubaki.monster_header import MonsterHeader + +from padinfo.view.base import BaseIdView +from padinfo.view.common import get_monster_from_ims +from padinfo.view.components.evo_scroll_mixin import EvoScrollView, MonsterEvolution +from padinfo.view.components.view_state_base_id import ViewStateBaseId + +if TYPE_CHECKING: + from dbcog.models.monster_model import MonsterModel + +MAX_MONS_TO_SHOW = 5 + + +class MaterialsViewState(ViewStateBaseId): + def __init__(self, original_author_id, menu_type, raw_query, query, monster: "MonsterModel", + alt_monsters: List[MonsterEvolution], is_jp_buffed: bool, query_settings: QuerySettings, + mats: List["MonsterModel"], usedin: List["MonsterModel"], gemid: Optional[str], + gemusedin: List["MonsterModel"], skillups: List["MonsterModel"], skillup_evo_count: int, link: str, + gem_override: bool, + reaction_list: List[str] = None, + extra_state=None): + super().__init__(original_author_id, menu_type, raw_query, query, monster, + alt_monsters, is_jp_buffed, query_settings, + reaction_list=reaction_list, + extra_state=extra_state) + self.reaction_list = reaction_list + self.link = link + self.skillup_evo_count = skillup_evo_count + self.skillups = skillups + self.gemusedin = gemusedin + self.mats = mats + self.usedin = usedin + self.gemid = gemid + self.gem_override = gem_override + + def serialize(self): + ret = super().serialize() + ret.update({ + 'pane_type': MaterialsView.VIEW_TYPE, + }) + return ret + + @classmethod + async def deserialize(cls, dbcog, user_config: UserConfig, ims: dict): + if ims.get('unsupported_transition'): + return None + monster = await get_monster_from_ims(dbcog, ims) + mats, usedin, gemid, gemusedin, skillups, skillup_evo_count, link, stackable = \ + await MaterialsViewState.do_query(dbcog, monster) + + if mats is None: + return None + + alt_monsters = cls.get_alt_monsters_and_evos(dbcog, monster) + raw_query = ims['raw_query'] + query = ims.get('query') or raw_query + query_settings = QuerySettings.deserialize(ims.get('query_settings')) + menu_type = ims['menu_type'] + original_author_id = ims['original_author_id'] + reaction_list = ims.get('reaction_list') + is_jp_buffed = dbcog.database.graph.monster_is_discrepant(monster) + + return cls(original_author_id, menu_type, raw_query, query, monster, + alt_monsters, is_jp_buffed, query_settings, + mats, usedin, gemid, gemusedin, skillups, skillup_evo_count, link, stackable, + reaction_list=reaction_list, + extra_state=ims) + + @staticmethod + async def do_query(dbcog, monster): + db_context = dbcog.database + mats = db_context.graph.evo_mats(monster) + usedin = db_context.graph.material_of_monsters(monster) + evo_gem = db_context.graph.evo_gem_monster(monster) + gemid = str(evo_gem.monster_no_na) if evo_gem else None + gemusedin = db_context.graph.material_of_monsters(evo_gem) if evo_gem else [] + skillups = [] + skillup_evo_count = 0 + link = MonsterLink.ilmina(monster) + + if monster.active_skill: + sums = [m for m in db_context.get_monsters_by_active(monster.active_skill.active_skill_id, + server=monster.server_priority) + if db_context.graph.monster_is_farmable_evo(m)] + sugs = [db_context.graph.evo_gem_monster(su) for su in sums] + vsums = [] + for su in sums: + if not any(susu in vsums for susu in db_context.graph.get_alt_monsters(su)): + vsums.append(su) + skillups = [su for su in vsums + if db_context.graph.monster_is_farmable_evo(su) and + db_context.graph.get_base_id(su) != db_context.graph.get_base_id(monster) and + su not in sugs] if monster.active_skill else [] + skillup_evo_count = len(sums) - len(vsums) + gem_override = False + + if not any([mats, usedin, gemusedin, skillups and not monster.is_stackable]): + return None, None, None, None, None, None, None, None + if not any([mats, usedin, skillups and not monster.is_stackable]): + mats, gemusedin, _, usedin, skillups, skillup_evo_count, link, _ \ + = await MaterialsViewState.do_query(dbcog, evo_gem) + gem_override = True + + return mats, usedin, gemid, gemusedin, skillups, skillup_evo_count, link, gem_override + + +def mat_use_field(mons, title, max_mons=MAX_MONS_TO_SHOW, query_settings: Optional[QuerySettings] = None): + text = None + if len(mons) == 0: + text = "None" + elif len(mons) > max_mons: + text = f"({len(mons) - max_mons} more monster{'s' if len(mons) - max_mons > 1 else ''}, see `^allmats` for full list)" + return EmbedField( + title, + Box(*(MonsterHeader.box_with_emoji( + em, query_settings=query_settings) for em in mons[:max_mons]), text)) + + +def skillup_field(mons, sec, link, query_settings): + text = None + text2 = None + if len(mons) == 0: + text = "None" + + if sec: + text2 = Box( + f"({max(len(mons) - MAX_MONS_TO_SHOW, 0) + sec} ", + LinkedText(f"more monster{'s' if max(len(mons) - MAX_MONS_TO_SHOW, 0) + sec > 1 else ''}", link), + f", incl. {sec} alt evo{'s' if sec > 1 else ''})", + delimiter="") + elif len(mons) > MAX_MONS_TO_SHOW: + text2 = f"({len(mons) - MAX_MONS_TO_SHOW} more monsters)" + + return EmbedField( + "Skillups", + Box(*(MonsterHeader.box_with_emoji( + em, query_settings=query_settings) for em in mons[:MAX_MONS_TO_SHOW]), text, text2)) + + +class MaterialsView(BaseIdView, EvoScrollView): + VIEW_TYPE = 'Materials' + + @classmethod + def embed(cls, state: MaterialsViewState): + # m: "MonsterModel", mats, usedin, gemid, gemusedin, skillups, skillup_evo_count, link + return EmbedView( + EmbedMain( + color=state.query_settings.embedcolor, + title=MonsterHeader.menu_title(state.monster, + is_tsubaki=state.alt_monsters[0].monster.monster_id == cls.TSUBAKI, + is_jp_buffed=state.is_jp_buffed).to_markdown(), + url=MonsterLink.header_link(state.monster, state.query_settings) + ), + embed_thumbnail=EmbedThumbnail(MonsterImage.icon(state.monster.monster_id)), + embed_footer=embed_footer_with_state(state), + embed_fields=[f for f in [ + mat_use_field(state.mats, "Evo materials", query_settings=state.query_settings) + if state.mats or not (state.monster.is_stackable or state.gem_override) else None, + mat_use_field(state.usedin, "Material for", 10, state.query_settings) + if state.usedin else None, + mat_use_field(state.gemusedin, "Evo gem ({}) is mat for".format(state.gemid), + 10 if state.gem_override else 5, state.query_settings) + if state.gemusedin else None, + skillup_field(state.skillups, state.skillup_evo_count, + state.link, state.query_settings) + if not (state.monster.is_stackable or state.gem_override) else None + ] if f is not None] + [cls.evos_embed_field(state)] + ) diff --git a/padinfo/view/monster_list/__init__.py b/padinfo/view/monster_list/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padinfo/view/monster_list/all_mats.py b/padinfo/view/monster_list/all_mats.py new file mode 100644 index 00000000..c9ecc2d1 --- /dev/null +++ b/padinfo/view/monster_list/all_mats.py @@ -0,0 +1,25 @@ +from typing import List, Optional, TYPE_CHECKING + +from padinfo.view.materials import MaterialsViewState +from padinfo.view.monster_list.monster_list import MonsterListViewState, MonsterListQueriedProps + +if TYPE_CHECKING: + from dbcog.models.monster_model import MonsterModel + + +class AllMatsViewState(MonsterListViewState): + VIEW_STATE_TYPE = "AllMats" + + @classmethod + async def do_query(cls, dbcog, monster: "MonsterModel") -> Optional[List["MonsterModel"]]: + _, usedin, _, gemusedin, _, _, _, _ = await MaterialsViewState.do_query(dbcog, monster) + if usedin is None and gemusedin is None: + return None + monster_list = usedin or gemusedin + return monster_list + + @classmethod + async def query_from_ims(cls, dbcog, ims) -> MonsterListQueriedProps: + monster = await dbcog.find_monster(ims['raw_query'], ims['original_author_id']) + monster_list = await cls.do_query(dbcog, monster) + return MonsterListQueriedProps(monster_list) diff --git a/padinfo/view/monster_list/id_search.py b/padinfo/view/monster_list/id_search.py new file mode 100644 index 00000000..0826f192 --- /dev/null +++ b/padinfo/view/monster_list/id_search.py @@ -0,0 +1,40 @@ +from typing import List, Optional, TYPE_CHECKING + +from tsutils.query_settings.enums import EvoGrouping +from tsutils.query_settings.query_settings import QuerySettings + +from padinfo.view.monster_list.monster_list import MonsterListViewState, MonsterListQueriedProps + +if TYPE_CHECKING: + from dbcog.models.monster_model import MonsterModel + + +class IdSearchViewState(MonsterListViewState): + VIEW_STATE_TYPE = "IdSearch" + + @classmethod + async def do_query(cls, dbcog, query, original_author_id, query_settings: QuerySettings) \ + -> Optional[MonsterListQueriedProps]: + found_monsters, extra_info = await dbcog.find_monsters(query, original_author_id) + + if not found_monsters: + return None + + # print(query_settings.serialize()) + if query_settings.evogrouping == EvoGrouping.splitevos: + return found_monsters + used = set() + monster_list = [] + for mon in found_monsters: + base_id = dbcog.database.graph.get_base_id(mon) + if base_id not in used: + used.add(base_id) + monster_list.append(mon) + + return MonsterListQueriedProps(monster_list, extra_info) + + @classmethod + async def query_from_ims(cls, dbcog, ims) -> MonsterListQueriedProps: + queried_props = await cls.do_query(dbcog, ims['raw_query'], ims['original_author_id'], + QuerySettings.deserialize(ims['query_settings'])) + return queried_props diff --git a/padinfo/view/monster_list/monster_list.py b/padinfo/view/monster_list/monster_list.py new file mode 100644 index 00000000..0cfabb2a --- /dev/null +++ b/padinfo/view/monster_list/monster_list.py @@ -0,0 +1,280 @@ +from typing import List, Optional, TYPE_CHECKING, Dict + +from discordmenu.embed.base import Box +from discordmenu.embed.components import EmbedField, EmbedMain +from discordmenu.embed.text import BoldText +from discordmenu.embed.view import EmbedView +from tsutils.emoji import char_to_emoji +from tsutils.menu.components.config import UserConfig +from tsutils.menu.components.footers import embed_footer_with_state +from tsutils.menu.view.view_state_base import ViewStateBase +from tsutils.query_settings.query_settings import QuerySettings +from tsutils.tsubaki.monster_header import MonsterHeader + +if TYPE_CHECKING: + from dbcog.models.monster_model import MonsterModel + from dbcog.find_monster.extra_info import SubqueryData, ExtraInfo + + +class MonsterListQueriedProps: + def __init__(self, monster_list: List["MonsterModel"], extra_info: "ExtraInfo" = None): + self.extra_info = extra_info + self.monster_list = monster_list + + +class MonsterListViewState(ViewStateBase): + VIEW_STATE_TYPE: str + MAX_ITEMS_PER_PANE = 11 + + def __init__(self, original_author_id, menu_type, query, + queried_props: MonsterListQueriedProps, query_settings: QuerySettings, + title, message, + *, + current_page: int = 0, + current_index: int = None, + child_menu_type: str = None, + child_reaction_list: Optional[List] = None, + reaction_list=None, + extra_state=None, + child_message_id=None + ): + super().__init__(original_author_id, menu_type, query, + extra_state=extra_state) + paginated_monsters = self.paginate(queried_props.monster_list) + self.queried_props = queried_props + self.extra_info = self.queried_props.extra_info + self.current_index = current_index + self.current_page = current_page + self.page_count = len(paginated_monsters) + self.idle_message = message + self.child_message_id = child_message_id + self.child_menu_type = child_menu_type + self.child_reaction_list = child_reaction_list + self.title = title + self.paginated_monsters = paginated_monsters + self.reaction_list = reaction_list + self.query = query + self.query_settings = query_settings + + @property + def current_monster_id(self) -> Optional[int]: + if self.current_index is None: + return None + return self.monster_list[self.current_index].monster_id + + @property + def monster_list(self) -> List["MonsterModel"]: + return self.paginated_monsters[self.current_page] + + def serialize(self): + ret = super().serialize() + ret.update({ + 'pane_type': MonsterListView.VIEW_TYPE, + 'title': self.title, + 'monster_list': [m.monster_id for m in self.monster_list], + 'query_settings': self.query_settings.serialize(), + 'current_page': self.current_page, + 'reaction_list': self.reaction_list, + 'child_message_id': self.child_message_id, + 'idle_message': self.idle_message, + 'current_index': self.current_index, + 'child_menu_type': self.child_menu_type, + 'child_reaction_list': self.child_reaction_list, + }) + return ret + + def get_serialized_child_extra_ims(self): + extra_ims = { + 'is_child': True, + 'reaction_list': self.child_reaction_list, + 'menu_type': self.child_menu_type, + 'resolved_monster_id': self.current_monster_id, + 'query_settings': self.query_settings.serialize(), + 'idle_message': self.idle_message, + } + return extra_ims + + @classmethod + async def deserialize(cls, dbcog, user_config: UserConfig, ims: dict): + if ims.get('unsupported_transition'): + return None + title = ims['title'] + + queried_props = await cls.query_from_ims(dbcog, ims) + current_page = ims['current_page'] + current_index = ims.get('current_index') + + raw_query = ims['raw_query'] + query = ims.get('query') or raw_query + query_settings = QuerySettings.deserialize(ims.get('query_settings')) + original_author_id = ims['original_author_id'] + menu_type = ims['menu_type'] + reaction_list = ims.get('reaction_list') + child_message_id = ims.get('child_message_id') + child_menu_type = ims.get('child_menu_type') + child_reaction_list = ims.get('child_reaction_list') + idle_message = ims.get('idle_message') + return cls(original_author_id, menu_type, query, + queried_props, query_settings, + title, idle_message, + current_page=current_page, + current_index=current_index, + child_menu_type=child_menu_type, + child_reaction_list=child_reaction_list, + reaction_list=reaction_list, + extra_state=ims, + child_message_id=child_message_id + ) + + @classmethod + async def query_from_ims(cls, dbcog, ims) -> MonsterListQueriedProps: + ... + + @staticmethod + def paginate(monster_list: List["MonsterModel"]) -> List[List["MonsterModel"]]: + paginated_monsters = [monster_list[i:i + MonsterListViewState.MAX_ITEMS_PER_PANE] + for i in range(0, len(monster_list), MonsterListViewState.MAX_ITEMS_PER_PANE)] + return paginated_monsters + + @classmethod + async def query_paginated_from_ims(cls, dbcog, ims) -> List[List["MonsterModel"]]: + queried_props = await cls.query_from_ims(dbcog, ims) + return cls.paginate(queried_props.monster_list) + + def increment_page(self): + if self.current_page < len(self.paginated_monsters) - 1: + self.current_page = self.current_page + 1 + self.current_index = None + return + self.current_page = 0 + if len(self.paginated_monsters) > 1: + self.current_index = None + + def decrement_page(self): + if self.current_page > 0: + self.current_page = self.current_page - 1 + self.current_index = None + return + self.current_page = len(self.paginated_monsters) - 1 + if len(self.paginated_monsters) > 1: + self.current_index = None + + async def increment_index(self, _dbcog): + max_index = len(self.paginated_monsters[self.current_page]) - 1 + if self.current_index is None: + self.current_index = 0 + return + if self.current_index < max_index: + self.current_index = self.current_index + 1 + return + if self.current_index == max_index and self.current_page < len(self.paginated_monsters) - 1: + self.current_page = self.current_page + 1 + self.current_index = 0 + return + if self.current_index == max_index: + self.current_page = 0 + self.current_index = 0 + + async def decrement_index(self, _dbcog): + max_index = len(self.paginated_monsters[self.current_page]) - 1 + if self.current_index is None: + self.current_index = max_index + return + if self.current_index > 0: + self.current_index = self.current_index - 1 + return + if self.current_index == 0 and self.current_page > 0: + self.current_page = self.current_page - 1 + self.current_index = MonsterListViewState.MAX_ITEMS_PER_PANE - 1 + return + if self.current_index == 0: + # respond with left but then set the current index to the max thing possible + self.current_page = len(self.paginated_monsters) - 1 + self.current_index = len(self.paginated_monsters[-1]) - 1 + + def set_index(self, new_index): + self.current_index = new_index + + +class MonsterListView: + VIEW_TYPE = 'MonsterList' + + @classmethod + def monster_list(cls, monsters: List["MonsterModel"], current_monster_id: int, query_settings: QuerySettings, + offset=0): + if not len(monsters): + return [] + return [MonsterHeader.box_with_emoji( + mon, link=True, prefix=cls.get_emoji(offset + i, current_monster_id), + query_settings=query_settings) for i, mon in enumerate(monsters)] + + @classmethod + def get_emoji(cls, i: int, _current_monster_id: int): + return char_to_emoji(str(i)) + + @classmethod + def has_subqueries(cls, state): + if state.queried_props.extra_info is None: + return False + return state.queried_props.extra_info.subquery_data + + @classmethod + def get_title(cls, state): + if not cls.has_subqueries(state): + return None + return state.title + + @classmethod + def embed(cls, state: MonsterListViewState): + fields = [] + if not cls.has_subqueries(state): + fields.append( + EmbedField(state.title, + Box(*cls.monster_list(state.monster_list, state.current_monster_id, state.query_settings))) + ) + + else: + cur_subq_id = None + cur_mon_list = [] + offset = 0 + i = 0 + for m in state.monster_list: + subq_id = state.extra_info.get_subquery_mon(m.monster_id) + if cur_mon_list and subq_id != cur_subq_id: + title = MonsterHeader.box_with_emoji( + state.extra_info.get_monster(cur_subq_id), query_settings=state.query_settings, + link=False) + fields.append( + EmbedField( + title, + Box(*cls.monster_list( + cur_mon_list, state.current_monster_id, state.query_settings, offset=offset))) + ) + cur_mon_list = [] + offset += i + i = 0 + cur_mon_list.append(m) + cur_subq_id = subq_id + i += 1 + + title = MonsterHeader.box_with_emoji( + state.extra_info.get_monster(cur_subq_id), query_settings=state.query_settings, link=False) + fields.append( + EmbedField( + title, + Box(*cls.monster_list( + cur_mon_list, state.current_monster_id, state.query_settings, offset=offset))) + ) + + fields.append(EmbedField(BoldText('Page'), + Box('{} of {}'.format(state.current_page + 1, state.page_count)), + inline=True + )) + + return EmbedView( + EmbedMain( + title=cls.get_title(state), + color=state.query_settings.embedcolor, + ), + embed_footer=embed_footer_with_state(state), + embed_fields=fields) diff --git a/padinfo/view/monster_list/scroll.py b/padinfo/view/monster_list/scroll.py new file mode 100644 index 00000000..78194633 --- /dev/null +++ b/padinfo/view/monster_list/scroll.py @@ -0,0 +1,131 @@ +from typing import List, Optional, TYPE_CHECKING + +from tsutils.menu.components.config import UserConfig +from tsutils.query_settings.query_settings import QuerySettings + +from padinfo.view.monster_list.monster_list import MonsterListView, MonsterListViewState, MonsterListQueriedProps + +if TYPE_CHECKING: + from dbcog.models.monster_model import MonsterModel + from dbcog.database_context import DbContext + + +class ScrollViewState(MonsterListViewState): + VIEW_STATE_TYPE = "Scroll" + + def __init__(self, original_author_id, menu_type, query, + queried_props: MonsterListQueriedProps, query_settings: QuerySettings, + title, message, current_monster_id: int, + *, + current_page: int = 0, + current_index: int = None, + child_menu_type: str = None, + child_reaction_list: Optional[List] = None, + reaction_list=None, + extra_state=None, + child_message_id=None + ): + super().__init__(original_author_id, menu_type, query, queried_props, query_settings, title, message, + current_page=current_page, current_index=current_index, child_menu_type=child_menu_type, + child_reaction_list=child_reaction_list, reaction_list=reaction_list, + extra_state=extra_state, child_message_id=child_message_id) + + self._current_monster_id = current_monster_id + + @property + def current_monster_id(self) -> int: + return self._current_monster_id + + def serialize(self): + ret = super().serialize() + ret.update({ + 'current_monster_id': self._current_monster_id + }) + return ret + + @classmethod + async def deserialize(cls, dbcog, user_config: UserConfig, ims: dict): + if ims.get('unsupported_transition'): + return None + title = ims['title'] + + queried_props = await cls.query_from_ims(dbcog, ims) + current_page = ims['current_page'] + current_index = ims.get('current_index') + + raw_query = ims['raw_query'] + query = ims.get('query') or raw_query + query_settings = QuerySettings.deserialize(ims.get('query_settings')) + original_author_id = ims['original_author_id'] + menu_type = ims['menu_type'] + reaction_list = ims.get('reaction_list') + child_message_id = ims.get('child_message_id') + child_menu_type = ims.get('child_menu_type') + child_reaction_list = ims.get('child_reaction_list') + idle_message = ims.get('idle_message') + return ScrollViewState(original_author_id, menu_type, query, + queried_props, query_settings, + title, idle_message, ims['current_monster_id'], + current_page=current_page, + current_index=current_index, + child_menu_type=child_menu_type, + child_reaction_list=child_reaction_list, + reaction_list=reaction_list, + extra_state=ims, + child_message_id=child_message_id + ) + + @classmethod + async def do_query(cls, dbcog, monster) -> Optional[MonsterListQueriedProps]: + prev_monster = dbcog.database.graph.numeric_prev_monster(monster) + next_monster = dbcog.database.graph.numeric_next_monster(monster) + + monster_list = [] + + if prev_monster is not None: + monster_list.append(prev_monster) + if next_monster is not None: + monster_list.append(next_monster) + + return MonsterListQueriedProps(monster_list) + + async def decrement_index(self, dbcog): + db_context: "DbContext" = dbcog.database + monster = db_context.graph.get_monster(self.current_monster_id, server=self.query_settings.server) + + prev_monster: "MonsterModel" = dbcog.database.graph.numeric_prev_monster(monster) + if prev_monster is None: + # TODO: raise an error here so that the "omg yikes" sign shows up once that's based on an error again + return + monster_list = await self.do_query(dbcog, prev_monster) + + self._current_monster_id = prev_monster.monster_id + self.paginated_monsters = [monster_list] + + async def increment_index(self, dbcog): + db_context: "DbContext" = dbcog.database + monster = db_context.graph.get_monster(self.current_monster_id, server=self.query_settings.server) + next_monster = dbcog.database.graph.numeric_next_monster(monster) + if next_monster is None: + # TODO: raise an error here so that the "omg yikes" sign shows up once that's based on an error again + return + monster_list = await self.do_query(dbcog, next_monster) + + self._current_monster_id = next_monster.monster_id + self.paginated_monsters = [monster_list] + + @classmethod + async def query_from_ims(cls, dbcog, ims) -> MonsterListQueriedProps: + monster = await dbcog.find_monster(ims['raw_query'], ims['original_author_id']) + queried_props = await cls.do_query(dbcog, monster) + return queried_props + + +class ScrollView(MonsterListView): + emojis = ['\N{BLACK LEFT-POINTING TRIANGLE}', '\N{BLACK RIGHT-POINTING TRIANGLE}'] + + @classmethod + def get_emoji(cls, i: int, current_monster_id: int): + if current_monster_id == 1: + return cls.emojis[1] + return cls.emojis[i] diff --git a/padinfo/view/monster_list/static_monster_list.py b/padinfo/view/monster_list/static_monster_list.py new file mode 100644 index 00000000..951b7652 --- /dev/null +++ b/padinfo/view/monster_list/static_monster_list.py @@ -0,0 +1,33 @@ +from typing import List, TYPE_CHECKING + +from tsutils.query_settings.query_settings import QuerySettings + +from padinfo.view.monster_list.monster_list import MonsterListViewState + +if TYPE_CHECKING: + from dbcog.models.monster_model import MonsterModel + from dbcog.database_context import DbContext + + +class StaticMonsterListViewState(MonsterListViewState): + VIEW_STATE_TYPE = "StaticMonsterList" + + def serialize(self): + ret = super().serialize() + ret.update({ + 'full_monster_list': [m.monster_id for page in self.paginated_monsters for m in page], + }) + return ret + + @classmethod + async def do_query(cls, dbcog, monster_list, server) -> List["MonsterModel"]: + db_context: "DbContext" = dbcog.database + monster_list = [db_context.graph.get_monster(int(m), server=server) for m in monster_list] + return monster_list + + @classmethod + async def query_from_ims(cls, dbcog, ims) -> List["MonsterModel"]: + monster_ids = ims['full_monster_list'] + query_settings = QuerySettings.deserialize(ims['query_settings']) + monster_list = await cls.do_query(dbcog, monster_ids, query_settings.server) + return monster_list diff --git a/padinfo/view/otherinfo.py b/padinfo/view/otherinfo.py new file mode 100644 index 00000000..2a223256 --- /dev/null +++ b/padinfo/view/otherinfo.py @@ -0,0 +1,94 @@ +from typing import TYPE_CHECKING + +import prettytable +from discordmenu.embed.base import Box +from discordmenu.embed.components import EmbedField, EmbedMain +from discordmenu.embed.text import LabeledText, Text +from discordmenu.embed.view import EmbedView +from redbot.core.utils.chat_formatting import box +from tsutils.menu.components.footers import embed_footer_with_state +from tsutils.tsubaki.links import MonsterLink +from tsutils.tsubaki.monster_header import MonsterHeader + +from padinfo.view.base import BaseIdView +from padinfo.view.components.evo_scroll_mixin import EvoScrollView +from padinfo.view.components.view_state_base_id import ViewStateBaseId +from padinfo.view.links import LinksView + +if TYPE_CHECKING: + from dbcog.models.monster_model import MonsterModel + + +class OtherInfoViewState(ViewStateBaseId): + def serialize(self): + ret = super().serialize() + ret.update({ + 'pane_type': OtherInfoView.VIEW_TYPE, + }) + return ret + + +def statsbox(m, plus: int): + stat_cols = ['', 'HP', 'ATK', 'RCV'] + tbl = prettytable.PrettyTable(stat_cols) + tbl.hrules = prettytable.NONE + tbl.vrules = prettytable.NONE + tbl.align = "l" + levels = (m.level, 110, 120) if m.limit_mult > 0 else (m.level,) + inh_tuple = (False, True) if plus == 297 else (False,) + for lv in levels: + for inh in inh_tuple: + hp, atk, rcv, _ = m.stats(lv, plus=plus, inherit=inh) + row_name = '(Inh)' if inh else 'Lv{}'.format(lv) + tbl.add_row([row_name, hp, atk, rcv]) + return box(tbl.get_string()) + + +class OtherInfoView(BaseIdView, EvoScrollView): + VIEW_TYPE = 'OtherInfo' + + @classmethod + def embed(cls, state: OtherInfoViewState): + m: "MonsterModel" = state.monster + return EmbedView( + EmbedMain( + color=state.query_settings.embedcolor, + title=MonsterHeader.menu_title(state.monster, + is_tsubaki=state.alt_monsters[0].monster.monster_id == cls.TSUBAKI, + is_jp_buffed=state.is_jp_buffed).to_markdown(), + url=MonsterLink.header_link(m, state.query_settings)), + embed_footer=embed_footer_with_state(state), + embed_fields=[ + EmbedField( + "Stats at +297:", + Box( + # need to put these on the same line to get around discord's insane + # whitespace margins around code blocks + Text(statsbox(m, plus=297) + 'Stats at +0:'), + Text(statsbox(m, plus=0)), + LabeledText("JP Name", m.name_ja), + LinksView.linksbox(m), + LabeledText("JP Added", str(m.reg_date)) if m.reg_date else None, + LabeledText("Series", m.series.name_en), + Box( + LabeledText("Sell MP", '{:,}'.format(m.sell_mp)), + LabeledText("Buy MP", '{:,}'.format(m.buy_mp)) if m.buy_mp else None, + delimiter=' '), + Box( + LabeledText("Sell Gold", '{:,}'.format(m.sell_gold)) + ), + Box( + LabeledText("XP to Max", '{:.1f}'.format(m.exp / 1000000).rstrip('0').rstrip('.') + 'M' + if m.exp >= 1000000 else '{:,}'.format(m.exp)), + LabeledText("Max Level", str(m.level)), + delimiter=' '), + Box( + LabeledText("Weighted Stats", str(m.stats()[3])), + Text('LB {} (+{}%)'.format(m.stats(lv=110)[3], m.limit_mult)) if m.limit_mult > 0 else None, + delimiter=' | '), + LabeledText("Fodder EXP", '{:,}'.format(m.fodder_exp)), + Box( + LabeledText("Rarity", str(m.rarity)), + LabeledText("Cost", str(m.cost)), + delimiter=' '))), + cls.evos_embed_field(state)]) diff --git a/padinfo/view/pantheon.py b/padinfo/view/pantheon.py new file mode 100644 index 00000000..ccff6161 --- /dev/null +++ b/padinfo/view/pantheon.py @@ -0,0 +1,180 @@ +from typing import List, TYPE_CHECKING + +from discordmenu.embed.base import Box +from discordmenu.embed.components import EmbedField, EmbedMain, EmbedThumbnail +from discordmenu.embed.view import EmbedView +from tsutils.menu.components.config import UserConfig +from tsutils.menu.components.footers import embed_footer_with_state +from tsutils.query_settings.query_settings import QuerySettings +from tsutils.tsubaki.custom_emoji import get_attribute_emoji_by_enum, get_attribute_emoji_by_monster, get_rarity_emoji +from tsutils.tsubaki.links import MonsterImage, MonsterLink +from tsutils.tsubaki.monster_header import MonsterHeader + +from padinfo.view.base import BaseIdView +from padinfo.view.common import get_monster_from_ims +from padinfo.view.components.evo_scroll_mixin import EvoScrollView, MonsterEvolution +from padinfo.view.components.view_state_base_id import ViewStateBaseId + +if TYPE_CHECKING: + from dbcog.models.monster_model import MonsterModel + +MAX_MONS_TO_SHOW = 20 +MAT_TYPES = ['Evolve', 'Awoken', 'Enhance', 'Vendor'] +NIL_ATT = 'Nil' + + +class PantheonViewState(ViewStateBaseId): + def __init__(self, original_author_id, menu_type, raw_query, query, monster: "MonsterModel", + alt_monsters, is_jp_buffed, query_settings, + pantheon_list: List[MonsterEvolution], series_name: str, base_monster, + reaction_list: List[str] = None, + extra_state=None): + super().__init__(original_author_id, menu_type, raw_query, query, monster, + alt_monsters, is_jp_buffed, query_settings, + reaction_list=reaction_list, + extra_state=extra_state) + self.series_name = series_name + self.pantheon_list = pantheon_list + self.base_monster = base_monster + + def serialize(self): + ret = super().serialize() + ret.update({ + 'pane_type': PantheonView.VIEW_TYPE, + }) + return ret + + @classmethod + async def deserialize(cls, dbcog, user_config: UserConfig, ims: dict): + if ims.get('unsupported_transition'): + return None + monster = await get_monster_from_ims(dbcog, ims) + pantheon_list, series_name, base_monster = await PantheonViewState.do_query(dbcog, monster) + + if pantheon_list is None: + return None + + alt_monsters = cls.get_alt_monsters_and_evos(dbcog, monster) + raw_query = ims['raw_query'] + query = ims.get('query') or raw_query + query_settings = QuerySettings.deserialize(ims.get('query_settings')) + original_author_id = ims['original_author_id'] + menu_type = ims['menu_type'] + reaction_list = ims.get('reaction_list') + is_jp_buffed = dbcog.database.graph.monster_is_discrepant(monster) + + return cls(original_author_id, menu_type, raw_query, query, monster, + alt_monsters, is_jp_buffed, query_settings, + pantheon_list, series_name, base_monster, + reaction_list=reaction_list, + extra_state=ims) + + @classmethod + def make_series_name(cls, series_name, filter_strings): + return '{} [Filters: {}]'.format(series_name, ' '.join(filter_strings)) + + @classmethod + async def do_query(cls, dbcog, monster): + # filtering rules: + # 1. don't show monsters that only have mat types (or show only mats if monster is a mat) + # 2. if still too many, show only monsters with the same base rarity + # 3. if still too many, show only monsters with the same base main attribute (any monster + # with nil main attribute is compared using its subattribute) + # 4. if still too many, show only monsters that match both base attributes + # 5. if still too many, truncate (and probably redefine pantheon) + + db_context = dbcog.database + series_name = monster.series.name_en + full_pantheon = db_context.get_monsters_by_series(monster.series_id, server=monster.server_priority) + if not full_pantheon: + return None, None, None + + base_mon = db_context.graph.get_base_monster(monster) + + base_list = [m for m in full_pantheon if db_context.graph.monster_is_base(m)] + if 0 < len(base_list) <= MAX_MONS_TO_SHOW: + return base_list, series_name, base_mon + + # if monster has only mat types, show only mats, otherwise show everything else + type_list = [] + if all(t.name in MAT_TYPES for t in monster.types): + series_name += ' (Mat)' + type_list = [m for m in base_list if all(t.name in MAT_TYPES for t in m.types)] + else: + type_list = [m for m in base_list if any(t.name not in MAT_TYPES for t in m.types)] + if 0 < len(type_list) <= MAX_MONS_TO_SHOW: + return type_list, series_name, base_mon + + filters = [] + + rarity_list = [m for m in type_list if m.rarity == base_mon.rarity] + filters.append(get_rarity_emoji(base_mon.rarity)) + if 0 < len(rarity_list) <= MAX_MONS_TO_SHOW: + return rarity_list, cls.make_series_name(series_name, filters), base_mon + + main_att = base_mon.attr1 + sub_att = base_mon.attr2 + + # if the monster has nil main attribute, do comparisons using its subattribute + # if any monster in the list has nil main attribute, also compare using its subattribute + main_att_list = [] + att_emoji = None + if main_att.name == NIL_ATT: + att_emoji = get_attribute_emoji_by_enum(sub_att) + main_att_list = [m for m in rarity_list if m.attr1.name == sub_att.name + or (m.attr1.name == NIL_ATT + and m.attr2.name == sub_att.name)] + else: + att_emoji = get_attribute_emoji_by_enum(main_att) + main_att_list = [m for m in rarity_list if m.attr1.name == main_att.name + or (m.attr1.name == NIL_ATT + and m.attr2.name == main_att.name)] + if 0 < len(main_att_list) <= MAX_MONS_TO_SHOW: + # append after check this time, because if we go to subatt we only want one emoji + filters.append(att_emoji) + return main_att_list, cls.make_series_name(series_name, filters), base_mon + + sub_att_list = [m for m in main_att_list if m.attr1.name == main_att.name + and m.attr2.name == sub_att.name] + filters.append(get_attribute_emoji_by_monster(base_mon)) + if 0 < len(sub_att_list) <= MAX_MONS_TO_SHOW: + return sub_att_list, cls.make_series_name(series_name, filters), base_mon + + # if we've managed to get here, just cut it off + pantheon_list = sub_att_list[:MAX_MONS_TO_SHOW] + filters.append('(still too many, truncated)') + + return pantheon_list, cls.make_series_name(series_name, filters), base_mon + + +def _pantheon_lines(monsters, base_monster, query_settings): + if not len(monsters): + return [] + return [ + MonsterHeader.box_with_emoji(mon, link=mon.monster_id != base_monster.monster_id, query_settings=query_settings) + for mon in sorted(monsters, key=lambda x: int(x.monster_id)) + ] + + +class PantheonView(BaseIdView, EvoScrollView): + VIEW_TYPE = 'Pantheon' + + @classmethod + def embed(cls, state: PantheonViewState): + fields = [EmbedField( + 'Pantheon: {}'.format(state.series_name), + Box(*_pantheon_lines(state.pantheon_list, state.base_monster, state.query_settings)) + ), + cls.evos_embed_field(state)] + + return EmbedView( + EmbedMain( + color=state.query_settings.embedcolor, + title=MonsterHeader.menu_title(state.monster, + is_tsubaki=state.alt_monsters[0].monster.monster_id == cls.TSUBAKI, + is_jp_buffed=state.is_jp_buffed).to_markdown(), + url=MonsterLink.header_link(state.monster, state.query_settings)), + embed_footer=embed_footer_with_state(state), + embed_fields=fields, + embed_thumbnail=EmbedThumbnail(MonsterImage.icon(state.monster.monster_id)), + ) diff --git a/padinfo/view/pic.py b/padinfo/view/pic.py new file mode 100644 index 00000000..fed17c81 --- /dev/null +++ b/padinfo/view/pic.py @@ -0,0 +1,64 @@ +from discordmenu.embed.base import Box +from discordmenu.embed.components import EmbedBodyImage, EmbedField, EmbedMain +from discordmenu.embed.text import LinkedText, Text +from discordmenu.embed.view import EmbedView +from tsutils.menu.components.footers import embed_footer_with_state +from tsutils.tsubaki.links import MonsterImage, MonsterLink +from tsutils.tsubaki.monster_header import MonsterHeader + +from padinfo.view.base import BaseIdView +from padinfo.view.components.evo_scroll_mixin import EvoScrollView +from padinfo.view.components.view_state_base_id import ViewStateBaseId + + +class PicViewState(ViewStateBaseId): + + def serialize(self): + ret = super().serialize() + ret.update({ + 'pane_type': PicView.VIEW_TYPE, + }) + return ret + + +class PicView(BaseIdView, EvoScrollView): + VIEW_TYPE = 'Pic' + + @classmethod + def embed(cls, state: PicViewState): + url = MonsterImage.picture(state.monster.monster_id) + animated = state.monster.has_animation + fields = [EmbedField( + 'Extra Links', + Box( + Box( + Text('Animation:'), + LinkedText('(MP4)', MonsterImage.video(state.monster.monster_no_jp)), + Text('|'), + LinkedText('(GIF)', MonsterImage.gif(state.monster.monster_no_jp)), + delimiter=' ' + ) if animated else None, + Box( + Text('Orb Skin:'), + LinkedText('Regular', MonsterImage.orb_skin(state.monster.orb_skin_id)), + Text('|'), + LinkedText('Color Blind', MonsterImage.orb_skin_colorblind(state.monster.orb_skin_id)), + delimiter=' ' + ) if state.monster.orb_skin_id else None, + ) + ), + cls.evos_embed_field(state) + ] + + return EmbedView( + EmbedMain( + color=state.query_settings.embedcolor, + title=MonsterHeader.menu_title(state.monster, + is_tsubaki=state.alt_monsters[0].monster.monster_id == cls.TSUBAKI, + is_jp_buffed=state.is_jp_buffed).to_markdown(), + url=MonsterLink.header_link(state.monster, state.query_settings) + ), + embed_footer=embed_footer_with_state(state), + embed_fields=fields, + embed_body_image=EmbedBodyImage(url), + ) diff --git a/padinfo/view/series_scroll.py b/padinfo/view/series_scroll.py new file mode 100644 index 00000000..3e23bf34 --- /dev/null +++ b/padinfo/view/series_scroll.py @@ -0,0 +1,270 @@ +from typing import List, TYPE_CHECKING + +from discordmenu.embed.base import Box +from discordmenu.embed.components import EmbedField, EmbedMain +from discordmenu.embed.text import BoldText +from discordmenu.embed.view import EmbedView +from tsutils.emoji import char_to_emoji +from tsutils.menu.components.config import UserConfig +from tsutils.menu.components.footers import embed_footer_with_state +from tsutils.menu.view.view_state_base import ViewStateBase +from tsutils.query_settings.query_settings import QuerySettings +from tsutils.tsubaki.monster_header import MonsterHeader + +if TYPE_CHECKING: + from dbcog.models.monster_model import MonsterModel + from dbcog.database_context import DbContext + + +class SeriesScrollViewState(ViewStateBase): + MAX_ITEMS_PER_PANE = 11 + + def __init__(self, original_author_id, menu_type, raw_query, query, series_id, + paginated_monsters: List[List["MonsterModel"]], current_page, rarity: int, + query_settings: QuerySettings, + all_rarities: List[int], + title, message, + current_index: int = None, + max_len_so_far: int = None, + reaction_list=None, extra_state=None, + child_message_id=None): + super().__init__(original_author_id, menu_type, raw_query, + extra_state=extra_state) + self.current_index = current_index + self.all_rarities = all_rarities + self.paginated_monsters = paginated_monsters + self.current_page = current_page or 0 + self.series_id = series_id + self.rarity = rarity + self.query_settings = query_settings + self.idle_message = message + self.child_message_id = child_message_id + self.title = title + self.reaction_list = reaction_list + self.query = query + self._max_len_so_far = max(max_len_so_far or len(self.monster_list), len(self.monster_list)) + + @property + def monster_list(self) -> List["MonsterModel"]: + return self.paginated_monsters[self.current_page] + + @property + def max_len_so_far(self) -> int: + self._max_len_so_far = max(len(self.monster_list), self._max_len_so_far) + return self._max_len_so_far + + @property + def current_monster_id(self) -> int: + return self.monster_list[self.current_index].monster_id + + @property + def pages_in_rarity(self) -> int: + return len(self.paginated_monsters) + + def serialize(self): + ret = super().serialize() + ret.update({ + 'pane_type': SeriesScrollView.VIEW_TYPE, + 'series_id': self.series_id, + 'query_settings': self.query_settings.serialize(), + 'current_page': self.current_page, + 'pages_in_rarity': self.pages_in_rarity, + 'title': self.title, + 'rarity': self.rarity, + 'all_rarities': self.all_rarities, + 'reaction_list': self.reaction_list, + 'child_message_id': self.child_message_id, + 'idle_message': self.idle_message, + 'max_len_so_far': self.max_len_so_far, + 'current_index': self.current_index, + }) + return ret + + def get_serialized_child_extra_ims(self, emoji_names, menu_type): + extra_ims = { + 'is_child': True, + 'reaction_list': emoji_names, + 'menu_type': menu_type, + 'resolved_monster_id': self.current_monster_id, + 'query_settings': self.query_settings.serialize(), + 'idle_message': self.idle_message + } + return extra_ims + + @staticmethod + async def deserialize(dbcog, user_config: UserConfig, ims: dict): + if ims.get('unsupported_transition'): + return None + series_id = ims['series_id'] + rarity = ims['rarity'] + all_rarities = ims['all_rarities'] + query_settings = QuerySettings.deserialize(ims.get('query_settings')) + paginated_monsters = await SeriesScrollViewState.do_query(dbcog, series_id, rarity, query_settings.server) + current_page = ims['current_page'] + title = ims['title'] + + raw_query = ims['raw_query'] + query = ims.get('query') or raw_query + original_author_id = ims['original_author_id'] + menu_type = ims['menu_type'] + reaction_list = ims.get('reaction_list') + child_message_id = ims.get('child_message_id') + current_index = ims.get('current_index') + current_monster_list = paginated_monsters[current_page] + max_len_so_far = max(ims['max_len_so_far'] or len(current_monster_list), len(current_monster_list)) + idle_message = ims.get('idle_message') + + return SeriesScrollViewState(original_author_id, menu_type, raw_query, query, series_id, + paginated_monsters, current_page, rarity, query_settings, + all_rarities, + title, idle_message, + current_index=current_index, + max_len_so_far=max_len_so_far, + reaction_list=reaction_list, + extra_state=ims, + child_message_id=child_message_id) + + @staticmethod + async def do_query(dbcog, series_id, rarity, server): + db_context: "DbContext" = dbcog.database + all_series_monsters = db_context.get_monsters_by_series(series_id, server=server) + base_monsters_of_rarity = list(filter( + lambda m: db_context.graph.monster_is_base(m) and m.rarity == rarity, all_series_monsters)) + paginated_monsters = [base_monsters_of_rarity[i:i + SeriesScrollViewState.MAX_ITEMS_PER_PANE] + for i in range( + 0, len(base_monsters_of_rarity), SeriesScrollViewState.MAX_ITEMS_PER_PANE)] + return paginated_monsters + + @staticmethod + def query_all_rarities(dbcog, series_id, server): + db_context: "DbContext" = dbcog.database + return sorted({m.rarity for m in db_context.get_all_monsters(server) if + m.series_id == series_id and db_context.graph.monster_is_base(m)}) + + @staticmethod + async def query_from_ims(dbcog, ims) -> List[List["MonsterModel"]]: + series_id = ims['series_id'] + rarity = ims['rarity'] + query_settings = QuerySettings.deserialize(ims['query_settings']) + paginated_monsters = await SeriesScrollViewState.do_query(dbcog, series_id, rarity, query_settings.server) + return paginated_monsters + + async def decrement_page(self, dbcog): + if self.current_page > 0: + self.current_page = self.current_page - 1 + self.current_index = None + else: + # if there are multiple rarities, decrementing first page will change rarity + if len(self.all_rarities) > 1: + rarity_index = self.all_rarities.index(self.rarity) + self.rarity = self.all_rarities[rarity_index - 1] + self.paginated_monsters = await SeriesScrollViewState.do_query(dbcog, self.series_id, self.rarity, + self.query_settings.server) + self.current_index = None + self.current_page = len(self.paginated_monsters) - 1 + + if len(self.paginated_monsters) > 1: + self.current_index = None + + async def increment_page(self, dbcog): + if self.current_page < len(self.paginated_monsters) - 1: + self.current_page = self.current_page + 1 + self.current_index = None + else: + # if there are multiple rarities, incrementing last page will change rarity + if len(self.all_rarities) > 1: + rarity_index = self.all_rarities.index(self.rarity) + self.rarity = self.all_rarities[(rarity_index + 1) % len(self.all_rarities)] + self.paginated_monsters = await SeriesScrollViewState.do_query(dbcog, self.series_id, self.rarity, + self.query_settings.server) + self.current_index = None + self.current_page = 0 + + if len(self.paginated_monsters) > 1: + self.current_index = None + + async def decrement_index(self, dbcog): + if self.current_index is None: + self.current_index = len(self.monster_list) - 1 + return + if self.current_index > 0: + self.current_index = self.current_index - 1 + return + await self.decrement_page(dbcog) + self.current_index = len(self.monster_list) - 1 + + async def increment_index(self, dbcog): + if self.current_index is None: + self.current_index = 0 + return + if self.current_index < len(self.monster_list) - 1: + self.current_index = self.current_index + 1 + return + await self.increment_page(dbcog) + self.current_index = 0 + + def set_index(self, new_index: int): + # don't want to go out of range, which will forget current index, break next, and break prev + if new_index < len(self.monster_list): + self.current_index = new_index + + +class SeriesScrollView: + VIEW_TYPE = 'SeriesScroll' + + @staticmethod + def embed(state: SeriesScrollViewState): + fields = [ + EmbedField(BoldText('Current rarity: {}'.format(state.rarity)), + Box(*SeriesScrollView._monster_list( + state.monster_list, + state.current_index, + state.query_settings))), + EmbedField(BoldText('Rarities'), + Box( + SeriesScrollView._all_rarity_text(state), + ), inline=True + ), + EmbedField(BoldText('Page'), + Box('{} of {}'.format(state.current_page + 1, state.pages_in_rarity)), + inline=True + ) + ] + + return EmbedView( + EmbedMain( + title=state.title, + color=state.query_settings.embedcolor, + ), + embed_footer=embed_footer_with_state(state), + embed_fields=fields) + + @staticmethod + def _all_rarity_text(state): + lines = [] + for r in state.all_rarities: + if r != state.rarity: + lines.append(str(r)) + else: + lines.append('**{}**'.format(state.rarity)) + return ', '.join(lines) + + @staticmethod + def _monster_list(monsters, current_index, query_settings: QuerySettings): + if not len(monsters): + return [] + return [ + MonsterHeader.box_with_emoji( + mon, + link=SeriesScrollView._is_linked(i, current_index), + prefix=char_to_emoji(str(i)), + query_settings=query_settings + ) + for i, mon in enumerate(monsters) + ] + + @staticmethod + def _is_linked(i, current_index): + if current_index is None: + return True + return i != current_index diff --git a/padinfo/view/transforminfo.py b/padinfo/view/transforminfo.py new file mode 100644 index 00000000..3675329e --- /dev/null +++ b/padinfo/view/transforminfo.py @@ -0,0 +1,235 @@ +from typing import TYPE_CHECKING + +from discordmenu.embed.base import Box +from discordmenu.embed.components import EmbedField, EmbedMain, EmbedThumbnail +from discordmenu.embed.text import BoldText, LabeledText, Text +from discordmenu.embed.view import EmbedView +from tsutils.menu.components.config import UserConfig +from tsutils.menu.components.footers import embed_footer_with_state +from tsutils.menu.view.view_state_base import ViewStateBase +from tsutils.query_settings.enums import LsMultiplier +from tsutils.query_settings.query_settings import QuerySettings +from tsutils.tsubaki.custom_emoji import get_emoji +from tsutils.tsubaki.links import MonsterImage, MonsterLink +from tsutils.tsubaki.monster_header import MonsterHeader + +from padinfo.view.components.base_id_main_view import BaseIdMainView + +if TYPE_CHECKING: + from dbcog.models.monster_model import MonsterModel + from dbcog.database_context import DbContext + + +class TransformInfoQueriedProps: + def __init__(self, acquire_raw, previous_transforms, awoken_skill_map): + self.acquire_raw = acquire_raw + self.previous_transforms = previous_transforms + self.awoken_skill_map = awoken_skill_map + + +class TransformInfoViewState(ViewStateBase): + def __init__(self, original_author_id, menu_type, raw_query, base_mon, transformed_mon, + tfinfo_queried_props: TransformInfoQueriedProps, monster_ids, is_jp_buffed, query_settings, + reaction_list): + super().__init__(original_author_id, menu_type, raw_query, extra_state=None) + self.base_mon = base_mon + self.transformed_mon = transformed_mon + self.acquire_raw = tfinfo_queried_props.acquire_raw + self.previous_transforms = tfinfo_queried_props.previous_transforms + self.awoken_skill_map = tfinfo_queried_props.awoken_skill_map + self.monster_ids = monster_ids + self.is_jp_buffed = is_jp_buffed + self.query_settings: QuerySettings = query_settings + self.reaction_list = reaction_list + + def serialize(self): + ret = super().serialize() + ret.update({ + 'query_settings': self.query_settings.serialize(), + 'resolved_monster_ids': self.monster_ids, + 'reaction_list': self.reaction_list + }) + return ret + + @staticmethod + async def deserialize(dbcog, user_config: UserConfig, ims: dict): + raw_query = ims['raw_query'] + original_author_id = ims['original_author_id'] + menu_type = ims['menu_type'] + monster_ids = ims['resolved_monster_ids'] + base_mon_id = monster_ids[0] + transformed_mon_id = monster_ids[1] + query_settings = QuerySettings.deserialize(ims.get('query_settings')) + + base_mon = dbcog.get_monster(base_mon_id, server=query_settings.server) + transformed_mon = dbcog.get_monster(transformed_mon_id, server=query_settings.server) + + tfinfo_queried_props = await TransformInfoViewState.do_query(dbcog, transformed_mon) + reaction_list = ims['reaction_list'] + is_jp_buffed = dbcog.database.graph.monster_is_discrepant( + base_mon) or dbcog.database.graph.monster_is_discrepant(transformed_mon) + + return TransformInfoViewState(original_author_id, menu_type, raw_query, + base_mon, transformed_mon, tfinfo_queried_props, monster_ids, is_jp_buffed, + query_settings, + reaction_list=reaction_list) + + @staticmethod + async def do_query(dbcog, transformed_mon) -> TransformInfoQueriedProps: + db_context = dbcog.database + db_context: "DbContext" + acquire_raw = db_context.graph.monster_acquisition(transformed_mon) + previous_transforms = db_context.graph.get_all_prev_transforms(transformed_mon) + awoken_skill_map = db_context.awoken_skill_map + return TransformInfoQueriedProps(acquire_raw, previous_transforms, awoken_skill_map) + + +def _get_tf_stat_diff_text(stat, tf_stat): + return Box( + Text(str(stat)), + Text('-> {}'.format(tf_stat)), + delimiter=' ' + ) + + +def transformat(text: str): + return '__{}__'.format(text) + + +class TransformInfoView(BaseIdMainView): + VIEW_TYPE = 'TransformInfo' + + @classmethod + def base_info(cls, m: "MonsterModel"): + return Box( + Box( + get_emoji(cls.down_emoji_name), + cls.normal_awakenings_row(m) if len(m.awakenings) != 0 + else Box(Text('No Awakenings')), + delimiter=' ' + ), + cls.super_awakenings_row(m) + ) + + @classmethod + def card_info(cls, base_mon: "MonsterModel", transformed_mon: "MonsterModel", acquire_raw): + rarity = Box( + LabeledText('Rarity', '{} -> {}'.format(base_mon.rarity, transformed_mon.rarity)), + Text("" if base_mon.orb_skin_id is None else "(Orb Skin)"), + Text("" if base_mon.bgm_id is None else "(BGM)"), + delimiter=' ' + ) + cost = LabeledText('Cost', '{} -> {}'.format(base_mon.cost, transformed_mon.cost)) + acquire = BoldText(acquire_raw) if acquire_raw else None + + return Box(rarity, cost, acquire) + + @classmethod + def stats(cls, base_mon: "MonsterModel", transformed_mon: "MonsterModel"): + base_hp, base_atk, base_rcv, base_weighted = base_mon.stats() + tf_hp, tf_atk, tf_rcv, tf_weighed = transformed_mon.stats() + return Box( + LabeledText('HP', _get_tf_stat_diff_text(base_hp, tf_hp)), + LabeledText('ATK', _get_tf_stat_diff_text(base_atk, tf_atk)), + LabeledText('RCV', _get_tf_stat_diff_text(base_rcv, tf_rcv)) + ) + + @classmethod + def transform_active_header(cls, m: "MonsterModel"): + active_skill = m.active_skill + active_cd = '({} cd)'.format(active_skill.cooldown_turns_min) if active_skill else 'None' + return Box( + get_emoji(cls.up_emoji_name), + BoldText('Transform Active Skill {}'.format(active_cd)), + delimiter=' ' + ) + + @classmethod + def base_active_header(cls, m: "MonsterModel"): + return Box( + get_emoji(cls.down_emoji_name), + BoldText('Base'), + cls.active_skill_header(m, []), + delimiter=' ' + ) + + @classmethod + def leader_header(cls, m: "MonsterModel", is_base: bool, lsmultiplier: LsMultiplier, base_mon: "MonsterModel"): + if is_base: + emoji = get_emoji(cls.down_emoji_name) + label = 'Base' + else: + emoji = get_emoji(cls.up_emoji_name) + label = 'Transform' + + return Box( + emoji, + BoldText(label), + cls.leader_skill_header(m, lsmultiplier, base_mon).to_markdown(), + delimiter=' ' + ) + + @classmethod + def embed(cls, state: TransformInfoViewState): + base_mon = state.base_mon + qs = state.query_settings + transformed_mon = state.transformed_mon + lsmultiplier = qs.lsmultiplier + fields = [ + EmbedField( + '/'.join(['{}'.format(t.name) for t in transformed_mon.types]), + Box( + Box( + get_emoji(cls.up_emoji_name), + cls.normal_awakenings_row(transformed_mon) + if len(transformed_mon.awakenings) != 0 else Box(Text('No Awakenings')), + delimiter=' ' + ), + TransformInfoView.base_info(base_mon), + cls.killers_row(base_mon, base_mon) + ), + ), + EmbedField( + BoldText(transformat('Card info')), + TransformInfoView.card_info(base_mon, transformed_mon, state.acquire_raw), + inline=True + ), + EmbedField( + BoldText('Stats -> ' + transformat('Transform')), + TransformInfoView.stats(base_mon, transformed_mon), + inline=True + ), + EmbedField( + transformat(TransformInfoView.transform_active_header(transformed_mon).to_markdown()), + Box( + Text(cls.active_skill_text( + transformed_mon.active_skill, state.awoken_skill_map, qs.skilldisplay)), + TransformInfoView.base_active_header(base_mon).to_markdown(), + Text(cls.active_skill_text( + base_mon.active_skill, state.awoken_skill_map, qs.skilldisplay)) + ) + ), + EmbedField( + transformat( + TransformInfoView.leader_header(transformed_mon, False, lsmultiplier, base_mon).to_markdown()), + Box( + Text(transformed_mon.leader_skill.desc if transformed_mon.leader_skill + else 'None'), + TransformInfoView.leader_header(base_mon, True, lsmultiplier, base_mon).to_markdown(), + Text(base_mon.leader_skill.desc if base_mon.leader_skill else 'None') + ) + ) + ] + + return EmbedView( + EmbedMain( + color=qs.embedcolor, + title=MonsterHeader.menu_title(transformed_mon, + is_tsubaki=False, + is_jp_buffed=state.is_jp_buffed).to_markdown(), + url=MonsterLink.header_link(transformed_mon, qs) + ), + embed_thumbnail=EmbedThumbnail(MonsterImage.icon(transformed_mon.monster_id)), + embed_footer=embed_footer_with_state(state), + embed_fields=fields + ) diff --git a/padle/__init__.py b/padle/__init__.py new file mode 100644 index 00000000..9e0cf0f2 --- /dev/null +++ b/padle/__init__.py @@ -0,0 +1,9 @@ +from .padle import PADle + +__red_end_user_data_statement__ = "Guess data for PADles played is saved." + + +async def setup(bot): + n = PADle(bot) + bot.add_cog(n) if not __import__('asyncio').iscoroutinefunction(bot.add_cog) else await bot.add_cog(n) + bot.loop.create_task(n.register_menu()) diff --git a/padle/help_texts.py b/padle/help_texts.py new file mode 100644 index 00000000..e253499a --- /dev/null +++ b/padle/help_texts.py @@ -0,0 +1,28 @@ +HELP_TEXT = """PADle is a Wordle-inspired guessing game, but with some differences! +{db} You are not guessing words! Instead, you are guessing monsters. +{db} The difference is based on awakenings, rarity, monster points, and a few other attributes. \ +Your current score screen will tell you if your guesses are correct, wrong, or misplaced (for awakenings). +{db} Because this can be much harder than guessing words, you have infinite guesses! +{db} Like Wordle, you can guess any monster, but only certain monsters can be the final PADle. \ +See `{p}padle validrules` to see what monsters count as "valid" final PADles. +{db} Share your number of guesses with `{p}padle score`! + +To see the main menu at any time, type `{p}padle`. To see this specific greeting again, type `{p}padle help`. + +Happy puzzling! {tsubaki}""" + +RULES_TEXT = """You are able to guess any monster, but the final PADle answer is chosen from a limited list of monsters. + +Valid monsters include the following: +{db} Super Reincarnated evolutions of pantheons +{db} 50k+ MP AND [have a Super Awakening OR are max transformed] +{db} 15k MP AND are non-event AND [have a Super Awakening OR are max transformed] + +The following monsters are exceptions to the above and will NOT be included: +{rd} Collab monsters (note this does not exclude Event monsters such as from Heroine or Relic Saga) +{rd} An equip evolution +{rd} Monsters not on the NA server +{rd} Monsters that are not max-transformed + +*NOTE: The Super Awakening check ensures monsters chosen are max-evo, for the most part.* +""" diff --git a/padle/info.json b/padle/info.json new file mode 100644 index 00000000..09c742e7 --- /dev/null +++ b/padle/info.json @@ -0,0 +1,23 @@ +{ + "author": [ + "fluff#2368" + ], + "description": "PADle, a Wordle game for PAD.", + "end_user_data_statement": "No personal data is stored.", + "install_msg": "Use [p]padle.", + "min_bot_version": "3.4.0", + "max_bot_version": "0.0.0", + "min_python_version": [ + 3, + 8, + 1 + ], + "hidden": false, + "disabled": false, + "required_cogs": {}, + "requirements": [ + "tsutils>=4.0.0" + ], + "tags": [] +} + \ No newline at end of file diff --git a/padle/menu/__init__.py b/padle/menu/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padle/menu/closable_embed.py b/padle/menu/closable_embed.py new file mode 100644 index 00000000..393c1624 --- /dev/null +++ b/padle/menu/closable_embed.py @@ -0,0 +1,9 @@ +from tsutils.menu.closable_embed_base import ClosableEmbedMenuBase +from padle.view.confirmation import PADleMonsterConfirmationView + + +class ClosableEmbedMenu(ClosableEmbedMenuBase): + MENU_TYPE = PADleMonsterConfirmationView.VIEW_TYPE + view_types = { + PADleMonsterConfirmationView.VIEW_TYPE: PADleMonsterConfirmationView + } diff --git a/padle/menu/globalstats.py b/padle/menu/globalstats.py new file mode 100644 index 00000000..981adbfe --- /dev/null +++ b/padle/menu/globalstats.py @@ -0,0 +1,77 @@ +from discord import Message +from discordmenu.embed.menu import EmbedMenu +from discordmenu.embed.wrapper import EmbedWrapper +from padle.view.globalstats_view import GlobalStatsView, GlobalStatsViewState +from tsutils.menu.components.panes import MenuPanes +from typing import Optional + + +class ScrollEmojis: + prev_page = '\N{BLACK LEFT-POINTING DOUBLE TRIANGLE}' + next_page = '\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE}' + home = '\N{HOUSE BUILDING}' + + +class GlobalStatsMenu: + MENU_TYPE = 'PADleGlobalStatsMenu' + + view_types = { + GlobalStatsViewState.VIEW_STATE_TYPE: GlobalStatsView + } + + @staticmethod + def menu(initial_control=None): + if initial_control is None: + initial_control = GlobalStatsMenu.control + embed = EmbedMenu(GlobalStatsMenuPanes.transitions(), initial_control) + return embed + + @classmethod + async def respond_with_left(cls, message: Optional[Message], ims, **data): + GlobalStatsViewState.decrement_page(ims) + state = await cls._get_view_state(ims, **data) + return GlobalStatsMenu.control(state) + + @classmethod + async def respond_with_right(cls, message: Optional[Message], ims, **data): + GlobalStatsViewState.increment_page(ims) + state = await cls._get_view_state(ims, **data) + return GlobalStatsMenu.control(state) + + @classmethod + async def _get_view_state(cls, ims: dict, **data) -> GlobalStatsViewState: + dbcog = data['dbcog'] + user_config = data['user_config'] + padle_cog = data['padle_cog'] + return await GlobalStatsViewState.deserialize(dbcog, user_config, padle_cog, ims) + + @classmethod + async def respond_with_pane(cls, message: Optional[Message], ims, **data) -> EmbedWrapper: + view_state = await cls._get_view_state(ims, **data) + return GlobalStatsMenu.control(view_state) + + @staticmethod + def control(state: GlobalStatsViewState): + if state is None: + return None + reaction_list = GlobalStatsMenuPanes.get_initial_reaction_list() + return EmbedWrapper( + GlobalStatsView.embed(state), + reaction_list + ) + + +class GlobalStatsMenuPanes(MenuPanes): + INITIAL_EMOJI = ScrollEmojis.home + + DATA = { + ScrollEmojis.next_page: (GlobalStatsMenu.respond_with_right, GlobalStatsView.VIEW_TYPE), + ScrollEmojis.prev_page: (GlobalStatsMenu.respond_with_left, GlobalStatsView.VIEW_TYPE), + ScrollEmojis.home: (GlobalStatsMenu.respond_with_pane, GlobalStatsView.VIEW_TYPE), + } + + HIDDEN_EMOJIS = [ScrollEmojis.home] + + @classmethod + def get_initial_reaction_list(cls): + return [ScrollEmojis.prev_page, ScrollEmojis.next_page] diff --git a/padle/menu/menu_map.py b/padle/menu/menu_map.py new file mode 100644 index 00000000..65f1e04b --- /dev/null +++ b/padle/menu/menu_map.py @@ -0,0 +1,12 @@ +from padle.menu.closable_embed import ClosableEmbedMenu +from padle.menu.globalstats import GlobalStatsMenu, GlobalStatsMenuPanes +from padle.menu.padle_scroll import PADleScrollMenu, PADleMenuPanes +from padle.menu.personal_stats import PersonalStatsMenu +from tsutils.menu.closable_embed_base import ClosableEmbedMenuPanes + +padle_menu_map = { + ClosableEmbedMenu.MENU_TYPE: (ClosableEmbedMenu, ClosableEmbedMenuPanes), + PersonalStatsMenu.MENU_TYPE: (PersonalStatsMenu, ClosableEmbedMenuPanes), + PADleScrollMenu.MENU_TYPE: (PADleScrollMenu, PADleMenuPanes), + GlobalStatsMenu.MENU_TYPE: (GlobalStatsMenu, GlobalStatsMenuPanes), +} diff --git a/padle/menu/padle_scroll.py b/padle/menu/padle_scroll.py new file mode 100644 index 00000000..6e1ca96b --- /dev/null +++ b/padle/menu/padle_scroll.py @@ -0,0 +1,79 @@ +from math import ceil +from typing import Optional + +from discord import Message +from discordmenu.embed.menu import EmbedMenu +from discordmenu.embed.wrapper import EmbedWrapper +from tsutils.menu.components.panes import MenuPanes + +from padle.view.padle_scroll_view import PADleScrollView, PADleScrollViewState + + +class ScrollEmojis: + prev_page = '\N{BLACK LEFT-POINTING DOUBLE TRIANGLE}' + next_page = '\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE}' + + +class PADleScrollMenu: + MENU_TYPE = 'PADleScrollMenu' + + view_types = { + PADleScrollViewState.VIEW_STATE_TYPE: PADleScrollView + } + + @staticmethod + def menu(initial_control=None): + if initial_control is None: + initial_control = PADleScrollMenu.control + embed = EmbedMenu(PADleMenuPanes.transitions(), initial_control) + return embed + + @classmethod + def get_num_pages_ims(cls, ims): + return ims['num_pages'] + + @classmethod + async def respond_with_left(cls, message: Optional[Message], ims, **data): + if ims['current_page'] < PADleScrollMenu.get_num_pages_ims(ims) - 1: + ims['current_page'] = ims['current_page'] + 1 + else: + ims['current_page'] = 0 + return await PADleScrollMenu.respond_with_pane(message, ims, **data) + + @classmethod + async def respond_with_right(cls, message: Optional[Message], ims, **data): + if ims['current_page'] > 0: + ims['current_page'] = ims['current_page'] - 1 + else: + ims['current_page'] = PADleScrollMenu.get_num_pages_ims(ims) - 1 + return await PADleScrollMenu.respond_with_pane(message, ims, **data) + + @classmethod + async def respond_with_pane(cls, message: Optional[Message], ims, **data) -> EmbedWrapper: + dbcog = data['dbcog'] + user_config = data['user_config'] + padle_cog = data['padle_cog'] + view_state = await PADleScrollViewState.deserialize(dbcog, padle_cog, user_config, ims) + return PADleScrollMenu.control(view_state) + + @staticmethod + def control(state: PADleScrollViewState): + if state is None: + return None + reaction_list = PADleMenuPanes.get_initial_reaction_list() + return EmbedWrapper( + PADleScrollView.embed(state), + reaction_list + ) + + +class PADleMenuPanes(MenuPanes): + INITIAL_EMOJI = ScrollEmojis.prev_page + DATA = { + ScrollEmojis.next_page: (PADleScrollMenu.respond_with_left, PADleScrollView.VIEW_TYPE), + ScrollEmojis.prev_page: (PADleScrollMenu.respond_with_right, PADleScrollView.VIEW_TYPE), + } + + @classmethod + def get_initial_reaction_list(cls): + return [ScrollEmojis.prev_page, ScrollEmojis.next_page] diff --git a/padle/menu/personal_stats.py b/padle/menu/personal_stats.py new file mode 100644 index 00000000..3b29a7f1 --- /dev/null +++ b/padle/menu/personal_stats.py @@ -0,0 +1,9 @@ +from padle.view.personal_stats_view import PersonalStatsView +from tsutils.menu.closable_embed_base import ClosableEmbedMenuBase + + +class PersonalStatsMenu(ClosableEmbedMenuBase): + MENU_TYPE = PersonalStatsView.VIEW_TYPE + view_types = { + PersonalStatsView.VIEW_TYPE: PersonalStatsView + } diff --git a/padle/monsterdiff.py b/padle/monsterdiff.py new file mode 100644 index 00000000..b98a00a8 --- /dev/null +++ b/padle/monsterdiff.py @@ -0,0 +1,147 @@ +from typing import TYPE_CHECKING + +from tsutils.tsubaki.custom_emoji import get_attribute_emoji_by_enum, get_awakening_emoji, get_emoji, get_rarity_emoji, \ + get_type_emoji + +if TYPE_CHECKING: + from dbcog.dbcog import MonsterModel + + +class MonsterDiff: + + def __init__(self, monster: "MonsterModel", guess_monster: "MonsterModel"): + self.monster = monster + self.guess_monster = guess_monster + self.awakenings_diff = self.get_awakenings_diff(monster, guess_monster) + self.rarity_diff = self.get_rarity_diff(monster, guess_monster) + self.attr_diff = self.get_attr_diff(monster, guess_monster) + self.mp_diff = self.get_mp_diff(monster, guess_monster) + self.type_diff = self.get_type_diff(monster, guess_monster) + + def get_diff_score(self): + score = 0 + for v in self.awakenings_diff: + score += v + for v in self.attr_diff: + score += v + for v in self.type_diff: + score += v + score += 1 if self.rarity_diff == 0 else 0 + score += 1 if self.mp_diff == 0 else 0 + return score + + def get_attr_diff(self, monster, guess_monster): + attr1 = guess_monster.attr1.name.lower() + attr2 = guess_monster.attr2.name.lower() + attr_feedback = [1 if attr1 == monster.attr1.name.lower() else 0, + 1 if attr2 == monster.attr2.name.lower() else 0] + if (attr1 == monster.attr2.name.lower() and + attr2 != monster.attr2.name.lower() and attr1 != monster.attr1.name.lower()): + attr_feedback[0] = 0.5 + if (attr2 == monster.attr1.name.lower() and + attr2 != monster.attr2.name.lower() and attr1 != monster.attr1.name.lower()): + attr_feedback[1] = 0.5 + return attr_feedback + + def get_awakenings_diff(self, monster, guess_monster): + guess_awo_count = len(guess_monster.awakenings) - guess_monster.superawakening_count + monster_awo_count = len(monster.awakenings) - monster.superawakening_count + unused = monster.awakenings[:monster_awo_count] + feedback = [] + for index, guess_awakening in enumerate(guess_monster.awakenings[:guess_awo_count]): + if (index < monster_awo_count and + monster.awakenings[index].awoken_skill_id == guess_awakening.awoken_skill_id): + feedback.append(1) + unused.remove(guess_awakening) + else: + feedback.append(0) + for index, guess_awakening in enumerate(guess_monster.awakenings[:guess_awo_count]): + if guess_awakening in unused and feedback[index] != 1: + feedback[index] = 0.5 + unused.remove(guess_awakening) + return feedback + + def get_rarity_diff(self, monster, guess_monster): + if monster.rarity == guess_monster.rarity: + return 0 + elif guess_monster.rarity > monster.rarity: + return -1 + else: + return 1 + + def get_mp_diff(self, monster, guess_monster): + if monster.sell_mp == guess_monster.sell_mp: + return 0 + elif guess_monster.sell_mp > monster.sell_mp: + return -1 + else: + return 1 + + def get_type_diff(self, monster, guess_monster): + diff = [] + for type in guess_monster.types: + if type in monster.types: + diff.append(1) + else: + diff.append(0) + return diff + + def get_name_line_feedback_text(self) -> str: + line = [] + attr1 = self.guess_monster.attr1.name.lower() + attr2 = self.guess_monster.attr2.name.lower() + attr_feedback = [] + for num in self.attr_diff: + if num == 0: + attr_feedback.append(get_emoji('red_cross_custom')) + elif num == 0.5: + attr_feedback.append(get_emoji('yellow_square_custom')) + else: + attr_feedback.append(get_emoji('green_check_custom')) + line.append(get_attribute_emoji_by_enum(self.guess_monster.attr1)) + line.append(attr_feedback[0] + " / ") + line.append(get_attribute_emoji_by_enum(self.guess_monster.attr2)) + line.append(attr_feedback[1] + " ") + line.append("[" + str(self.guess_monster.monster_id) + "] ") + line.append(self.guess_monster.name_en) + return "".join(line) + + def get_other_info_feedback_text(self) -> str: + line = [get_rarity_emoji(self.guess_monster.rarity)] + if self.rarity_diff == 0: + line.append(get_emoji('green_check_custom') + " | ") + elif self.rarity_diff == -1: + line.append("\N{DOWNWARDS BLACK ARROW}\N{VARIATION SELECTOR-16} | ") + else: + line.append("\N{UPWARDS BLACK ARROW}\N{VARIATION SELECTOR-16} | ") + for type in self.guess_monster.types: + line.append(get_type_emoji(type)) + if type in self.monster.types: + line.append(get_emoji('green_check_custom') + " ") + else: + line.append(get_emoji('red_cross_custom') + " ") + line.append(" | Sell MP: ") + line.append('{:,}'.format(self.guess_monster.sell_mp)) + if self.mp_diff == 0: + line.append(get_emoji('green_check_custom')) + elif self.mp_diff == -1: + line.append("\N{DOWNWARDS BLACK ARROW}\N{VARIATION SELECTOR-16}") + else: + line.append("\N{UPWARDS BLACK ARROW}\N{VARIATION SELECTOR-16}") + + return "".join(line) + + def get_awakenings_feedback_text(self) -> str: + awakes = [] + feedback = [] + guess_awo_count = len(self.guess_monster.awakenings) - self.guess_monster.superawakening_count + for index, guess_awake in enumerate(self.guess_monster.awakenings[:guess_awo_count]): + awakes.append(get_awakening_emoji(guess_awake.awoken_skill_id, guess_awake.name)) + val = self.awakenings_diff[index] + if val == 0: + feedback.append(get_emoji('red_cross_custom')) + elif val == 0.5: + feedback.append(get_emoji('yellow_square_custom')) + else: + feedback.append(get_emoji('green_check_custom')) + return "\n".join(["".join(awakes), "".join(feedback)]) diff --git a/padle/padle.py b/padle/padle.py new file mode 100644 index 00000000..b287d7ec --- /dev/null +++ b/padle/padle.py @@ -0,0 +1,783 @@ +import asyncio +import csv +import discord +import itertools +import json +import logging +import random +import re +from contextlib import suppress +from datetime import datetime +from discordmenu.embed.components import EmbedThumbnail, EmbedMain +from discordmenu.embed.view import EmbedView +from io import BytesIO, StringIO +from math import ceil +from padle.help_texts import HELP_TEXT, RULES_TEXT +from padle.menu.closable_embed import ClosableEmbedMenu +from padle.menu.globalstats import GlobalStatsMenu, GlobalStatsViewState +from padle.menu.menu_map import padle_menu_map +from padle.menu.padle_scroll import PADleScrollMenu, PADleScrollViewState +from padle.menu.personal_stats import PersonalStatsMenu +from padle.monsterdiff import MonsterDiff +from padle.view.confirmation import PADleMonsterConfirmationView, PADleMonsterConfirmationViewProps +from padle.view.personal_stats_view import PersonalStatsView, PersonalStatsViewProps +from redbot.core import Config, commands +from redbot.core.utils.chat_formatting import pagify +from tsutils.cogs.globaladmin import auth_check +from tsutils.emoji import NO_EMOJI, YES_EMOJI +from tsutils.helper_functions import conditional_iterator +from tsutils.menu.components.config import BotConfig +from tsutils.menu.view.closable_embed import ClosableEmbedViewState +from tsutils.query_settings.query_settings import QuerySettings +from tsutils.time import NA_TIMEZONE +from tsutils.tsubaki.custom_emoji import get_emoji +from tsutils.tsubaki.links import MonsterImage, MonsterLink +from tsutils.tsubaki.monster_header import MonsterHeader +from tsutils.user_interaction import get_user_confirmation, send_confirmation_message, send_cancellation_message, \ + get_user_reaction +from typing import TYPE_CHECKING, Any, List, Optional, Union + +if TYPE_CHECKING: + from dbcog.dbcog import DBCog + # from dbcog.monster_graph import MonsterGraph # used for creating the filter + +logger = logging.getLogger('red.padbot-cogs.padle') + + +class PADle(commands.Cog): + """A Wordle game for PAD""" + + # Used if no monster list is set + FALLBACK_PADLE_MONSTER = 3260 + menu_map = padle_menu_map + + def __init__(self, bot, *args, **kwargs): + super().__init__(*args, **kwargs) + self.bot = bot + self.config = Config.get_conf(self, identifier=94073) + self.config.register_user(todays_guesses=[], start=False, done=False, score=[], + edit_id=0, channel_id=0, all_guesses={}) + self.config.register_global(padle_today=self.FALLBACK_PADLE_MONSTER, + stored_day=self._day_today(), num_days=1, subs=[], + all_scores=[], save_daily_scores=[], monsters_list=[], tmrw_padle=0) + self.config.register_guild(allow=False) + self._daily_padle_loop = bot.loop.create_task(self.generate_padle()) + GACOG: Any = self.bot.get_cog("GlobalAdmin") + if GACOG: + GACOG.register_perm("padleadmin") + + async def red_get_data_for_user(self, *, user_id): + """Get a user's personal data.""" + all_guesses = await self.config.user_from_id(user_id).all_guesses() + if all_guesses: + data = (f"You have {len(all_guesses)} days of guess data stored. " + f"Here are your guesses:\n{json.dumps(all_guesses)}") + else: + data = f"No data is stored for user with ID {user_id}." + return {"user_data.txt": BytesIO(data.encode())} + + async def red_delete_data_for_user(self, *, requester, user_id): + """Delete a user's personal data.""" + await self.config.user_from_id(user_id).clear() + + async def register_menu(self): + await self.bot.wait_until_ready() + menulistener: Any = self.bot.get_cog("MenuListener") + if menulistener is None: + logger.warning("MenuListener is not loaded.") + return + await menulistener.register(self) + + async def get_dbcog(self) -> "DBCog": + dbcog: Any = self.bot.get_cog("DBCog") + if dbcog is None: + raise ValueError("DBCog cog is not loaded") + await dbcog.wait_until_ready() + return dbcog + + def cog_unload(self): + self._daily_padle_loop.cancel() + + async def get_menu_default_data(self, ims): + data = { + 'dbcog': await self.get_dbcog(), + 'user_config': await BotConfig.get_user(self.config, ims['original_author_id']), + 'padle_cog': self, + } + return data + + async def get_today_guesses(self, user: discord.User, current_day: Union[str, int]) -> Optional[List[int]]: + if current_day is None: + return None + if user is None: + return {} + return (await self.config.user(user).all_guesses()).get(str(current_day)) + + def _day_today(self): + return datetime.now(NA_TIMEZONE).day + + @commands.group() + async def padle(self, ctx): + """Commands pertaining to PADle""" + + @commands.group(aliases=['padleconfig']) + @auth_check('padleadmin') + async def padleadmin(self, ctx): + """Commands pertaining to PADle setup""" + + @padle.command() + async def help(self, ctx): + """Instructions for PADle""" + await ctx.send(self._get_help_text(ctx)) + + def _get_help_text(self, ctx): + args = {"db": get_emoji("db"), "p": ctx.prefix, "tsubaki": get_emoji("tsubaki")} + return HELP_TEXT.format(**args) + + @padle.command() + async def validrules(self, ctx): + """Show rules for the valid-answer monster list""" + args = {"db": get_emoji("db"), "rd": get_emoji("rd")} + await ctx.send(RULES_TEXT.format(**args)) + + @padle.command(aliases=["sub"]) + async def subscribe(self, ctx, sub_arg: bool = True): + """Subscribe to daily notifications of new PADles""" + prefix = ctx.prefix + subbed_users = await self.config.subs() + if not sub_arg: + if ctx.author.id in subbed_users: + async with self.config.subs() as subs: + subs.remove(ctx.author.id) + return await send_confirmation_message(ctx, "You will no longer receive notifications of new PADles.") + return await send_cancellation_message(ctx, "You are not subscribed.") + if ctx.author.id not in subbed_users: + async with self.config.subs() as subs: + subs.append(ctx.author.id) + return await send_confirmation_message(ctx, "You will now receive notifications of new PADles. " + "You can unsubscribe with `{}padle subscribe 0`.".format( + prefix)) + + confirmation = await get_user_confirmation(ctx, "You are already subscribed. Did you mean to unsubscribe?") + if confirmation: + async with self.config.subs() as subs: + subs.remove(ctx.author.id) + return await send_confirmation_message(ctx, "You will no longer receive notifications of new PADles.") + elif confirmation is None: + await send_cancellation_message(ctx, "Confirmation timeout") + else: + await ctx.send("No changes were made, you will still receive notifications of new PADles.") + + async def can_play_in_guild(self, ctx): + return (ctx.guild is not None and ctx.author.guild_permissions.administrator and ctx.guild.member_count < 10 and + await self.config.guild(ctx.guild).allow()) + + @padle.command() + async def start(self, ctx): + """Start a game of PADle""" + if await self.config.user(ctx.author).done(): + return await ctx.send("You have already finished today's PADle!") + if await self.config.user(ctx.author).start(): + return await ctx.send("You have already started a game!") + if (ctx.guild is not None and ctx.author.guild_permissions.administrator and ctx.guild.member_count < 10 and + not await self.config.guild(ctx.guild).allow()): + guild_confirmation = await get_user_confirmation(ctx, "It looks like you're in a private server. " + "Would you like to play PADle in this server" + " rather than in DMs?", timeout=30, + force_delete=False, show_feedback=True) + if guild_confirmation is None: + return await send_cancellation_message(ctx, "Confirmation timeout.") + if await self.config.guild(ctx.guild).allow(): + return await ctx.send("You have already enabled PADle in this server!") + if not guild_confirmation: + await send_confirmation_message(ctx, "PADles will continue being played in DMs.") + else: + await send_confirmation_message(ctx, "PADles can now be played in this server.") + await self.config.guild(ctx.guild).allow.set(True) + if ctx.guild is None: + message = "Start today's (#{}) PADle game?".format(await self.config.num_days()) + else: + message = "{}, start today's (#{}) PADle game?".format(ctx.author.name, await self.config.num_days()) + confirmation = await get_user_confirmation(ctx, message, timeout=30, force_delete=False, show_feedback=True) + if confirmation is None: + return await send_cancellation_message(ctx, "Confirmation timeout.") + if await self.config.user(ctx.author).start(): + return await send_cancellation_message(ctx, "You cannot start multiple games!") + if not confirmation: + return await send_cancellation_message(ctx, "The PADle game was not started.") + + prefix = ctx.prefix + em = discord.Embed(title="PADle #{}".format(await self.config.num_days()), type="rich", + description=f"Guess a card with `{prefix}padle guess `!\nIf " + f"you give up, use `{prefix}padle giveup`.\n" + "If you accidentally deleted the guess list message," + f" use `{prefix}padle resend`.", + color=discord.Color.teal()) + if await self.can_play_in_guild(ctx): + if len(await self.config.user(ctx.author).all_guesses()) == 0: + await ctx.send("Hello! It looks like this is your first PADle game.") + await ctx.send(self._get_help_text(ctx)) + await ctx.send(embed=em) + await self.config.user(ctx.author).start.set(True) + return + # else + try: + if len(await self.config.user(ctx.author).all_guesses()) == 0: + await ctx.author.send("Hello! It looks like this is your first PADle game.") + await ctx.author.send(self._get_help_text(ctx)) + await ctx.author.send(embed=em) + except discord.HTTPException: + return await send_cancellation_message(ctx, + "Looks like I can't DM you. Try checking your Privacy Settings.") + if ctx.guild is not None: + await send_confirmation_message(ctx, f"{ctx.author.name}, check your DMs!") + await self.config.user(ctx.author).start.set(True) + + @padle.command() + async def globalstats(self, ctx, day: int = 0): + """Global stats for the PADle (optionally on a specified day)""" + num_days = await self.config.num_days() + if day <= 0 or day > num_days: + day = await self.config.num_days() + + if day == num_days: + stats = await self.config.all_scores() + monster = None + else: + dbcog = await self.get_dbcog() + all = await self.config.save_daily_scores() + stats = all[day - 1][1] + monster = dbcog.get_monster(all[day - 1][0]) + query_settings = await QuerySettings.extract_raw(ctx.author, self.bot, "") + global_stats_menu = GlobalStatsMenu.menu() + state = GlobalStatsViewState(ctx.author.id, GlobalStatsMenu.MENU_TYPE, query_settings, "", + current_day=day, num_days=num_days, monster=monster, stats=stats) + await global_stats_menu.create(ctx, state) + + @padle.command() + async def stats(self, ctx): + """Personal PADle stats""" + if await self.config.user(ctx.author).start() and not await self.config.user(ctx.author).done(): + return await send_cancellation_message(ctx, "Please finish today's PADle before checking your stats!") + all_guesses = await self.config.user(ctx.author).all_guesses() + played = len(all_guesses) + save_daily = await self.config.save_daily_scores() + if played == 0: + return await send_cancellation_message(ctx, "You haven't played PADle yet!") + cur_streak = 0 + max_streak = 0 + wins = 0 + for day in range(1, await self.config.num_days() + 1): + if day == await self.config.num_days(): + correct_id = await self.config.padle_today() + else: + correct_id = save_daily[day - 1][0] + if str(day) in all_guesses and correct_id in all_guesses[str(day)]: + cur_streak += 1 + wins += 1 + elif day != await self.config.num_days() or await self.config.user(ctx.author).done(): + cur_streak = 0 + max_streak = max(max_streak, cur_streak) + flatten = itertools.chain.from_iterable + all_monsters_guessed = list(flatten(all_guesses.values())) + if not all_monsters_guessed: + all_monsters_guessed.append(self.FALLBACK_PADLE_MONSTER) + mode = max(set(all_monsters_guessed), key=all_monsters_guessed.count) + dbcog = await self.get_dbcog() + m = dbcog.get_monster(mode) + menu = PersonalStatsMenu.menu() + query_settings = await QuerySettings.extract_raw(ctx.author, self.bot, "") + props = PersonalStatsViewProps(query_settings, ctx.author.name, played, wins / played, + cur_streak, max_streak, m) + state = ClosableEmbedViewState(ctx.author.id, PersonalStatsMenu.MENU_TYPE, "", query_settings, + PersonalStatsView.VIEW_TYPE, props) + await menu.create(ctx, state) + + async def do_quit_early(self, ctx): + prefix = ctx.prefix + if ctx.guild is not None and not await self.can_play_in_guild(ctx): + await ctx.send("You can only play PADle in DMs!") + return True + if not await self.config.user(ctx.author).start(): + await ctx.send(f"You have not started the game of PADle yet, try `{prefix}padle start`!") + return True + if await self.config.user(ctx.author).done(): + await ctx.send("You have already played today's PADle!") + return True + return False + + @padle.command() + async def guess(self, ctx, *, guess): + """Guess a card for the daily PADle""" + if await self.do_quit_early(ctx): + return + dbcog = await self.get_dbcog() + + guess_monster = await dbcog.find_monster(guess, ctx.author.id) + if guess_monster is None: + close_menu = ClosableEmbedMenu.menu() + props = PADleMonsterConfirmationViewProps("Monster not found, please try again.") + query_settings = await QuerySettings.extract_raw(ctx.author, self.bot, guess) + state = ClosableEmbedViewState(ctx.author.id, ClosableEmbedMenu.MENU_TYPE, guess, + query_settings, PADleMonsterConfirmationView.VIEW_TYPE, props) + await close_menu.create(ctx, state) + return + + m_embed = EmbedView( + EmbedMain( + title=MonsterHeader.menu_title(guess_monster).to_markdown(), + description="Did you mean this monster?"), + embed_thumbnail=EmbedThumbnail( + MonsterImage.icon(guess_monster.monster_id))).to_embed() + confirmation = await get_user_reaction(ctx, m_embed, YES_EMOJI, NO_EMOJI, timeout=20) + if confirmation is None: + return await send_cancellation_message(ctx, "Confirmation timeout.") + if confirmation == NO_EMOJI: + return await send_cancellation_message(ctx, "Please guess again.") + if not await self.config.user(ctx.author).start(): + return await send_cancellation_message(ctx, + "You have not started a game yet! The PADle may have just expired.") + if await self.config.user(ctx.author).done(): + return await send_cancellation_message(ctx, "You have already finished today's PADle!") + async with self.config.user(ctx.author).todays_guesses() as todays_guesses: + todays_guesses.append(guess_monster.monster_id) + monster = dbcog.get_monster(int(await self.config.padle_today())) + cur_day = await self.config.num_days() + todays_guesses = await self.config.user(ctx.author).todays_guesses() + async with self.config.user(ctx.author).all_guesses() as all_guesses: + all_guesses[str(cur_day)] = todays_guesses + + channel = self.bot.get_channel(await self.config.user(ctx.author).channel_id()) + if channel is not None: + with suppress(discord.HTTPException): + del_msg = await channel.fetch_message(await self.config.user(ctx.author).edit_id()) + await del_msg.delete() + + guess_monster_diff = MonsterDiff(monster, guess_monster) + points = guess_monster_diff.get_diff_score() + + padle_menu = PADleScrollMenu.menu() + # query_settings = await QuerySettings.extract_raw(ctx.author, self.bot, guess) + cur_page = ceil(len(todays_guesses) / 5) - 1 + page_guesses = await PADleScrollViewState.do_queries(dbcog, + todays_guesses[((cur_page) * 5):((cur_page + 1) * 5)]) + state = PADleScrollViewState(ctx.author.id, PADleScrollMenu.MENU_TYPE, guess, monster=monster, + cur_day_page_guesses=page_guesses, current_day=cur_day, + current_page=cur_page, num_pages=cur_page + 1) + message = await padle_menu.create(ctx, state) + await self.config.user(ctx.author).edit_id.set(message.id) + await self.config.user(ctx.author).channel_id.set(ctx.channel.id) + + score = "\N{LARGE YELLOW SQUARE}" + if guess_monster.monster_id == monster.monster_id: + await ctx.send("You got the PADle in {} guesses! Use `{}padle score` to share your score.".format( + len(await self.config.user(ctx.author).todays_guesses()), ctx.prefix)) + await self.config.user(ctx.author).done.set(True) + async with self.config.all_scores() as all_scores: + all_scores.append(str(len(await self.config.user(ctx.author).todays_guesses()))) + score = "\N{LARGE GREEN SQUARE}" + else: + if points < 9: + score = "\N{LARGE ORANGE SQUARE}" + if points < 5: + score = "\N{LARGE RED SQUARE}" + async with self.config.user(ctx.author).score() as scores: + scores.append(score) + + @padle.command() + async def resend(self, ctx): + """Resends the guess list message""" + if await self.do_quit_early(ctx): + return + dbcog = await self.get_dbcog() + monster = dbcog.get_monster(int(await self.config.padle_today())) + cur_day = await self.config.num_days() + todays_guesses = await self.config.user(ctx.author).todays_guesses() + if len(todays_guesses) == 0: + return await send_cancellation_message(ctx, "You haven't guessed yet!") + padle_menu = PADleScrollMenu.menu() + cur_page = ceil(len(todays_guesses) / 5) - 1 + page_guesses = await PADleScrollViewState.do_queries(dbcog, + todays_guesses[((cur_page) * 5):((cur_page + 1) * 5)]) + state = PADleScrollViewState(ctx.author.id, PADleScrollMenu.MENU_TYPE, monster=monster, + cur_day_page_guesses=page_guesses, current_day=cur_day, + current_page=cur_page, num_pages=cur_page + 1) + message = await padle_menu.create(ctx, state) + await self.config.user(ctx.author).edit_id.set(message.id) + await self.config.user(ctx.author).channel_id.set(ctx.channel.id) + + @padle.command() + async def giveup(self, ctx): + """Give up on today's PADle""" + if await self.do_quit_early(ctx): + return + confirmation = await get_user_confirmation(ctx, "Are you sure you would like to give up?", timeout=20) + dbcog = await self.get_dbcog() + if not confirmation: + if confirmation is None: + await send_cancellation_message(ctx, "Confirmation timeout.") + return + monster = dbcog.get_monster(int(await self.config.padle_today())) + await self.config.user(ctx.author).done.set(True) + m_embed = EmbedView(EmbedMain(title=MonsterHeader.menu_title(monster).to_markdown(), + description="PADle #{}".format(await self.config.num_days()), + url=MonsterLink.ilmina(monster), color=discord.Color.red()), + embed_thumbnail=EmbedThumbnail(MonsterImage.icon(monster.monster_id))).to_embed() + await ctx.send(embed=m_embed) + await ctx.send(f"Use `{ctx.prefix}padle score` to share your score!") + async with self.config.user(ctx.author).score() as scores: + scores.append("\N{CROSS MARK}") + async with self.config.all_scores() as all_scores: + all_scores.append("X") + cur_day = await self.config.num_days() + todays_guesses = await self.config.user(ctx.author).todays_guesses() + async with self.config.user(ctx.author).all_guesses() as all_guesses: + all_guesses[str(cur_day)] = todays_guesses + + @padle.command(aliases=["share"]) + async def score(self, ctx): + """Share your PADle score""" + prefix = ctx.prefix + if not await self.config.user(ctx.author).done(): + await ctx.send("You have not done today's PADle yet!") + return + score = await self.config.user(ctx.author).score() + value = "X" if "\N{CROSS MARK}" in score else str(len(await self.config.user(ctx.author).todays_guesses())) + if ctx.guild is None: + msg = "PADle #{}: {}".format(await self.config.num_days(), value) + "\n" + "".join(score) + await ctx.send(msg) + await ctx.send(f"*Hint: You can use the command `{prefix}padle score` anywhere " + f"and have {ctx.me.name} automatically share your score for you!*") + else: + await ctx.message.delete() + msg = ctx.author.mention + "'s PADle #{}: {}".format(await self.config.num_days(), value) + "\n" + "".join( + score) + await ctx.send(msg) + + async def generate_padle(self): + async def is_day_change(): + cur_day = self._day_today() + old_day = await self.config.stored_day() + if cur_day != old_day: + await self.config.stored_day.set(cur_day) + return True + + await self.bot.wait_until_ready() + async for _ in conditional_iterator(is_day_change, poll_interval=10): + try: + async with self.config.save_daily_scores() as save_daily: + save_daily.append([await self.config.padle_today(), await self.config.all_scores()]) + tmrw_padle = await self.config.tmrw_padle() + if tmrw_padle == 0: + MONSTERS_LIST = await self.config.monsters_list() + if len(MONSTERS_LIST) == 0: + await self.config.padle_today.set(self.FALLBACK_PADLE_MONSTER) + else: + await self.config.padle_today.set(int(random.choice(MONSTERS_LIST))) + else: + await self.config.padle_today.set(tmrw_padle) + await self.config.tmrw_padle.set(0) + num = await self.config.num_days() + await self.config.num_days.set(num + 1) + all_users = await self.config.all_users() + await self.config.all_scores.set([]) + for userid in all_users: + user = self.bot.get_user(userid) + if user is None: + continue + # save past guesses only if started + if await self.config.user(user).start(): + async with self.config.user(user).all_guesses() as all_guesses: + all_guesses[str(num)] = await self.config.user(user).todays_guesses() + await self.config.user(user).todays_guesses.set([]) + # need to send message if a user is mid-game + if await self.config.user(user).start() and not await self.config.user(user).done(): + await user.send("The PADle expired; a new one is available.") + await self.config.user(user).start.set(False) + await self.config.user(user).done.set(False) + await self.config.user(user).score.set([]) + await self.config.user(user).edit_id.set(0) + await self.config.user(user).channel_id.set(0) + subbed_users = await self.config.subs() + for userid in subbed_users: + user = self.bot.get_user(userid) + if user is not None: + await user.send("PADle #{} is now available!".format(await self.config.num_days())) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Error in loop:") + + @padleadmin.command() + async def fullreset(self, ctx): + """Resets all stats and information""" + confirmation = await get_user_confirmation(ctx, + "Fully reset all stats and data in PADle? You cannot undo this!") + if confirmation is None: + return await send_cancellation_message(ctx, "Confirmation timeout.") + if not confirmation: + return await send_cancellation_message(ctx, "Nothing was reset.") + + MONSTERS_LIST = await self.config.monsters_list() + if len(MONSTERS_LIST) == 0: + await self.config.padle_today.set(self.FALLBACK_PADLE_MONSTER) + else: + await self.config.padle_today.set(int(random.choice(MONSTERS_LIST))) + await self.config.stored_day.set(self._day_today()) + await self.config.tmrw_padle.set(0) + await self.config.num_days.set(1) + await self.config.subs.set([]) + await self.config.all_scores.set([]) + await self.config.save_daily_scores.set([]) + all_users = await self.config.all_users() + for userid in all_users: + user = self.bot.get_user(userid) + if user is None: + continue + await self.config.user(user).todays_guesses.set([]) + # need to send message if a user is mid-game + if await self.config.user(user).start() and not await self.config.user(user).done(): + try: + await user.send("A full reset occured, the PADle expired.") + except: + pass + await self.config.user(user).start.set(False) + await self.config.user(user).done.set(False) + await self.config.user(user).score.set([]) + await self.config.user(user).edit_id.set(0) + await self.config.user(user).channel_id.set(0) + await self.config.user(user).all_guesses.set({}) + await ctx.tick() + + @padleadmin.command() + async def add(self, ctx, *ids: int): + """Adds space-separated list of monster IDs to the list of possible PADles""" + dbcog = await self.get_dbcog() + result = [] + valids = [] + monsters_list = await self.config.monsters_list() + for monster_id in ids: + m = dbcog.get_monster(monster_id) + if m is None: + result.append(f"{monster_id} is not a valid monster ID.") + continue + if not m.on_na or m.name_en is None: + result.append(f"{monster_id} is not a monster on the NA server.") + continue + if monster_id in monsters_list: + result.append( + f"{MonsterHeader.menu_title(m, use_emoji=True).to_markdown()} is already a possible PADle.") + continue + valids.append(monster_id) + result.append(f"{MonsterHeader.menu_title(m, use_emoji=True).to_markdown()} was added.") + async with self.config.monsters_list() as m_list: + m_list.extend(valids) + for page in pagify("\n".join(result)): + await ctx.send(page) + + @padleadmin.command() + async def remove(self, ctx, *ids: int): + """Removes space-separated list of monster IDs to the list of possible PADles""" + dbcog = await self.get_dbcog() + result = [] + valids = [] + monsters_list = await self.config.monsters_list() + for id in ids: + m = dbcog.get_monster(id) + if m is None: + result.append(f"{str(id)} is not a valid monster ID.") + continue + if not m.on_na or m.name_en is None: + result.append(f"{str(id)} is not a monster on the NA server.") + continue + if int(id) not in monsters_list: + result.append(f"{MonsterHeader.menu_title(m, use_emoji=True).to_markdown()} is not a possible PADle.") + continue + valids.append(int(id)) + result.append(f"{MonsterHeader.menu_title(m, use_emoji=True).to_markdown()} was removed.") + await self.config.monsters_list.set([m for m in monsters_list if m not in valids]) + for page in pagify("\n".join(result)): + await ctx.send(page) + + @padleadmin.command() + async def set(self, ctx): + """Set possible PADles to attached CSV/text file""" + if ctx.message.attachments: + try: + input = StringIO((await ctx.message.attachments[0].read()).decode("utf-8")) + reader = csv.reader(input, delimiter=",") + valid = [] + invalid = [] + dbcog = await self.get_dbcog() + for row in reader: + for text_id in row: + id = re.sub(" ", "", text_id) + if not id.isnumeric(): + invalid.append(id) + continue + m = dbcog.get_monster(int(id)) + if m is None or not m.on_na or m.name_en is None: + invalid.append(id) + continue + valid.append(int(id)) + if len(invalid) == 0: + await self.config.monsters_list.set(valid) + return await ctx.tick() + await send_cancellation_message(ctx, "The following IDs were invalid: " + f"{', '.join(invalid)}. Please remove them and try again.") + except Exception as e: + await send_cancellation_message(ctx, "Something went wrong.") + logger.exception("Error when setting CSV of PADles.") + else: + return await send_cancellation_message(ctx, "Looks like no file was attached!") + + @padleadmin.command() + async def list(self, ctx): + """Lists all possible PADles""" + dbcog = await self.get_dbcog() + monsters_list = await self.config.monsters_list() + monsters_list.sort() + result = [] + for id in monsters_list: + m = dbcog.get_monster(int(id)) + if m is None or not m.on_na or m.name_en is None: + # this should never happen but just in case + result.append(f"**Error**: {id} is a valid PADle but not a monster.") + continue + result.append(MonsterHeader.menu_title(m, use_emoji=True).to_markdown()) + for page in pagify("\n".join(result)): + await ctx.send(page) + await ctx.send(f"There are **{len(result)}** valid PADles!") + + @padleadmin.command(aliases=["settmrw", "settmrwpadle"]) + async def settomorrowpadle(self, ctx, id: int): + """Sets tomorrow's PADle to a specified monster ID""" + dbcog = await self.get_dbcog() + monsters_list = await self.config.monsters_list() + if id == 0: + confirmation = await get_user_confirmation(ctx, "Would you like to make tomorrow's PADle random?") + if confirmation is None: + return await send_cancellation_message(ctx, "Confirmation timeout") + if not confirmation: + return await ctx.send("No changes were made.") + await self.config.tmrw_padle.set(0) + return await send_confirmation_message(ctx, "Tomorrow's PADle will be random.") + if id not in monsters_list: + # for the most part, avoid abuse and ensure validness + return await ctx.send("This monster is not a valid PADle. If you would like to set it as tomorrow's" + " PADle, please add it to the list of PADles first.") + m = dbcog.get_monster(id) + title = MonsterHeader.menu_title(m, use_emoji=True).to_markdown() + confirmation = await get_user_confirmation(ctx, "Are you sure you would like to change tomorrow's PADle to " + f"{title}?") + if confirmation is None: + return await send_cancellation_message(ctx, "Confirmation timeout") + if not confirmation: + return await ctx.send("No change to tomorrow's PADle was made.") + await self.config.tmrw_padle.set(id) + prefix = ctx.prefix + await send_confirmation_message(ctx, f"Tomorrow's PADle will be {title}! " + f"Use `{prefix}padle settomorrowpadle 0` to undo this.") + + @padleadmin.command(aliases=["isin"]) + async def contains(self, ctx, *, query): + """Checks if a monster is in the list of possible PADles""" + monsters_list = await self.config.monsters_list() + dbcog = await self.get_dbcog() + monster = await dbcog.find_monster(query, ctx.author.id) + if monster is None: + return + title = MonsterHeader.menu_title(monster, use_emoji=True).to_markdown() + if monster.monster_id in monsters_list: + return await send_confirmation_message(ctx, f"{title} is a possible PADle.") + await send_cancellation_message(ctx, f"{title} is not a possible PADle.") + + @padleadmin.command() + async def advance(self, ctx): + """Advance day""" + confirmation = await get_user_confirmation(ctx, "Advance to the next day? You cannot undo this!") + if confirmation is None: + return await send_cancellation_message(ctx, "Confirmation timeout.") + if not confirmation: + return await send_cancellation_message(ctx, "The PADle was unchanged.") + try: + await self.config.stored_day.set(self._day_today()) + async with self.config.save_daily_scores() as save_daily: + save_daily.append([await self.config.padle_today(), await self.config.all_scores()]) + tmrw_padle = await self.config.tmrw_padle() + if tmrw_padle == 0: + MONSTERS_LIST = await self.config.monsters_list() + if len(MONSTERS_LIST) == 0: + await self.config.padle_today.set(self.FALLBACK_PADLE_MONSTER) + else: + await self.config.padle_today.set(int(random.choice(MONSTERS_LIST))) + else: + await self.config.padle_today.set(tmrw_padle) + await self.config.tmrw_padle.set(0) + num = await self.config.num_days() + await self.config.num_days.set(num + 1) + all_users = await self.config.all_users() + await self.config.all_scores.set([]) + for userid in all_users: + user = self.bot.get_user(userid) + if user is None: + continue + # save past guesses only if started + if await self.config.user(user).start(): + async with self.config.user(user).all_guesses() as all_guesses: + all_guesses[str(num)] = await self.config.user(user).todays_guesses() + await self.config.user(user).todays_guesses.set([]) + # need to send message if a user is mid-game + if await self.config.user(user).start() and not await self.config.user(user).done(): + await user.send("The PADle expired; a new one is available.") + await self.config.user(user).start.set(False) + await self.config.user(user).done.set(False) + await self.config.user(user).score.set([]) + await self.config.user(user).edit_id.set(0) + await self.config.user(user).channel_id.set(0) + subbed_users = await self.config.subs() + for userid in subbed_users: + user = self.bot.get_user(userid) + if user is not None: + await user.send("PADle #{} is now available!".format(await self.config.num_days())) + await ctx.tick() + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Error in loop:") + + @padleadmin.command() + async def filter(self, ctx): + """Re-creates the list of monsters""" + dbcog = await self.get_dbcog() + graph = dbcog.database.graph + final = [] + for i in range(1, graph.max_monster_id + 1): + monster = dbcog.get_monster(i) + if monster is None: + continue + if self.is_valid_padle(monster, graph): + final.append(str(i)) + await ctx.send("List of monsters:", file=discord.File(BytesIO(",".join(final).encode('utf-8')), "result.txt")) + await ctx.tick() + + def is_valid_padle(self, monster, graph): + next_trans = graph.get_next_transform(monster) + prev_trans = graph.get_prev_transform(monster) + return ( # Monster is in NA + monster.name_en is not None and monster.on_na and + # No collab monsters + monster.series.series_type != 'collab' and + # No equips + not monster.is_equip and + # No monsters with next transforms + next_trans is None and + monster.level >= 99 and + # A GFE (max transformed or has SA) + ((monster.sell_mp >= 50000 and (monster.superawakening_count > 1 or prev_trans is not None)) or + # OR a Super Reincarnated evo of a pantheon + ("Super Reincarnated" in monster.name_en and monster.sell_mp == 5000) or + # OR 15k non-collab, non-event monsters with SA / max transformed + (monster.sell_mp == 15000 and (monster.superawakening_count > 1 or prev_trans is not None) and + monster.series.series_type == 'regular')) + ) diff --git a/padle/view/__init__.py b/padle/view/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padle/view/confirmation.py b/padle/view/confirmation.py new file mode 100644 index 00000000..44a754a7 --- /dev/null +++ b/padle/view/confirmation.py @@ -0,0 +1,24 @@ +from tsutils.menu.view.closable_embed import ClosableEmbedViewState +from tsutils.menu.components.footers import embed_footer_with_state +from discordmenu.embed.view import EmbedView +from discordmenu.embed.components import EmbedMain +from discord import Color + + +class PADleMonsterConfirmationViewProps: + def __init__(self, title: str): + self.title = title + + +class PADleMonsterConfirmationView: + VIEW_TYPE = 'PADleConfirmation' + + @classmethod + def embed(cls, state: ClosableEmbedViewState, props: PADleMonsterConfirmationViewProps): + return EmbedView( + EmbedMain( + color=Color.red(), + title=props.title, + ), + embed_footer=embed_footer_with_state(state, text="Click the X to close.") + ) diff --git a/padle/view/globalstats_view.py b/padle/view/globalstats_view.py new file mode 100644 index 00000000..04612b15 --- /dev/null +++ b/padle/view/globalstats_view.py @@ -0,0 +1,121 @@ +from discordmenu.embed.components import EmbedMain, EmbedThumbnail, EmbedField +from discordmenu.embed.view import EmbedView +from discordmenu.embed.base import Box +from discordmenu.embed.text import BoldText +from tsutils.menu.components.config import UserConfig +from tsutils.menu.components.footers import embed_footer_with_state +from tsutils.menu.view.view_state_base import ViewStateBase +from tsutils.tsubaki.links import MonsterImage +from tsutils.tsubaki.monster_header import MonsterHeader +from tsutils.query_settings.query_settings import QuerySettings + + +class GlobalStatsViewState(ViewStateBase): + VIEW_STATE_TYPE: str = "PADleGlobalStatsView" + + def __init__(self, original_author_id, menu_type, query_settings: QuerySettings, raw_query="", + current_day=0, num_days=0, extra_state=None, reaction_list=None, monster=None, stats=[]): + super().__init__(original_author_id, menu_type, raw_query, + extra_state=extra_state) + self.query_settings = query_settings + self.reaction_list = reaction_list + self.current_day = current_day + self.num_days = num_days + self.monster = monster + self.stats = stats + + def serialize(self): + ret = super().serialize() + ret.update({ + 'current_day': self.current_day, + 'reaction_list': self.reaction_list, + 'num_days': self.num_days, + 'query_settings': self.query_settings.serialize() + }) + return ret + + @staticmethod + def increment_page(ims): + if ims['current_day'] < ims['num_days']: + ims['current_day'] = ims['current_day'] + 1 + else: + ims['current_day'] = 1 + + @staticmethod + def decrement_page(ims): + if ims['current_day'] > 1: + ims['current_day'] = ims['current_day'] - 1 + else: + ims['current_day'] = ims['num_days'] + + @classmethod + async def deserialize(cls, dbcog, _user_config: UserConfig, padle_cog, ims: dict): + original_author_id = ims['original_author_id'] + menu_type = ims['menu_type'] + reaction_list = ims['reaction_list'] + current_day = ims['current_day'] + num_days = ims['num_days'] + query_settings = QuerySettings.deserialize(ims.get('query_settings')) + daily_scores_list = await padle_cog.config.save_daily_scores() + cur_day_scores = await padle_cog.config.all_scores() + if num_days == current_day: + return GlobalStatsViewState(original_author_id, menu_type, query_settings, "", current_day=current_day, + num_days=num_days, reaction_list=reaction_list, monster=None, + stats=cur_day_scores) + else: + info = daily_scores_list[current_day - 1] + m = dbcog.get_monster(int(info[0])) + return GlobalStatsViewState(original_author_id, menu_type, query_settings, "", current_day=current_day, + num_days=num_days, reaction_list=reaction_list, monster=m, + stats=info[1]) + + +class GlobalStatsView: + VIEW_TYPE = 'PADleGlobalStats' + + @classmethod + def get_pages_footer(cls, state): + return "Day " + str(state.current_day) + "/" + str(state.num_days) + + @staticmethod + def embed(state: GlobalStatsViewState): + description = GlobalStatsView.get_description(state) + if state.num_days == state.current_day: + return EmbedView( + EmbedMain( + title=BoldText(f"PADle #{state.current_day}"), + color=state.query_settings.embedcolor, + ), + embed_fields=[EmbedField("Stats", Box(description))], + embed_footer=embed_footer_with_state(state, text=GlobalStatsView.get_pages_footer(state))) + else: + return EmbedView( + EmbedMain( + title=BoldText(f"PADle #{state.current_day}"), + description=Box(BoldText(MonsterHeader.menu_title(state.monster).to_markdown())), + color=state.query_settings.embedcolor, + ), + embed_fields=[EmbedField("Stats", Box(description))], + embed_footer=embed_footer_with_state(state, text=GlobalStatsView.get_pages_footer(state)), + embed_thumbnail=EmbedThumbnail(MonsterImage.icon(state.monster.monster_id))) + + @classmethod + def get_description(cls, state): + giveups = 0 + completes = 0 + average = 0 + for item in state.stats: + if item == "X": + giveups += 1 + else: + completes += 1 + average += int(item) + if completes == 0: + description = (f"**Total Wins**: {completes}\n**Total Losses**: {giveups}\n**Win Rate**: 0%\n" + "**Average Guess Count**: 0") + else: + description = ("**Total Wins**: {}\n**Total Losses**: {}\n**Win Rate**: {:.2%}\n" + "**Average Guess Count**: {:.2f}").format(completes, giveups, + completes / (completes + giveups), + (average / completes)) + return description diff --git a/padle/view/padle_scroll_view.py b/padle/view/padle_scroll_view.py new file mode 100644 index 00000000..fa08a646 --- /dev/null +++ b/padle/view/padle_scroll_view.py @@ -0,0 +1,100 @@ +from typing import TYPE_CHECKING + +from discordmenu.embed.components import EmbedMain, EmbedField +from discordmenu.embed.base import Box +from discordmenu.embed.view import EmbedView +from tsutils.menu.components.footers import embed_footer_with_state +from tsutils.menu.components.config import UserConfig +from tsutils.menu.view.view_state_base import ViewStateBase +from padle.monsterdiff import MonsterDiff + +from tsutils.menu.components.footers import TSUBAKI_FLOWER_ICON_URL + +if TYPE_CHECKING: + from dbcog.models.monster_model import MonsterModel + + +class PADleScrollViewState(ViewStateBase): + VIEW_STATE_TYPE: str = "PADleScrollView" + + def __init__(self, original_author_id, menu_type, raw_query="", monster=None, cur_day_page_guesses=[], + current_page=0, extra_state=None, reaction_list=None, current_day: int = 0, num_pages=0): + super().__init__(original_author_id, menu_type, raw_query, + extra_state=extra_state) + self.cur_day_page_guesses = cur_day_page_guesses + self.current_page = current_page + self.reaction_list = reaction_list + self.current_day = current_day + self.monster = monster + self.num_pages = num_pages + + def get_cur_fields(self): + fields = [] + for index, item in enumerate(self.cur_day_page_guesses): + guessed_mon = MonsterDiff(self.monster, item) + fields.append(EmbedField( + title=f"**Guess #{5 * (self.current_page) + index + 1}**:", + body=Box("\n".join( + [guessed_mon.get_name_line_feedback_text(), guessed_mon.get_other_info_feedback_text(), + guessed_mon.get_awakenings_feedback_text()])), + )) + return fields + + @classmethod + async def do_queries(cls, dbcog, guess_ids): + monster_list = [] + for id in guess_ids: + monster_list.append(dbcog.get_monster(int(id))) + return monster_list + + def get_pages_footer(self): + return "Page " + str(self.current_page + 1) + "/" + str(self.num_pages) + + @classmethod + def get_monster_ids_list(cls, monster_list): + ids = [] + for monster in monster_list: + ids.append(monster.monster_id) + return ids + + def serialize(self): + ret = super().serialize() + ret.update({ + 'current_page': self.current_page, + 'reaction_list': self.reaction_list, + 'current_day': self.current_day, + 'cur_monster': self.monster.monster_id, + 'num_pages': self.num_pages + }) + return ret + + @classmethod + async def deserialize(cls, dbcog, padle_cog, _user_config: UserConfig, ims: dict): + original_author_id = ims['original_author_id'] + menu_type = ims['menu_type'] + reaction_list = ims['reaction_list'] + cur_page = ims['current_page'] + current_day = ims['current_day'] + cur_monster = ims['cur_monster'] + num_pages = ims['num_pages'] + monster = dbcog.get_monster(int(cur_monster)) + user = padle_cog.bot.get_user(original_author_id) + today_guesses = await padle_cog.get_today_guesses(user, ims.get('current_day')) + cur_day_page_guesses = await cls.do_queries(dbcog, today_guesses[((cur_page) * 5):((cur_page + 1) * 5)]) + return PADleScrollViewState(original_author_id, menu_type, "", current_page=cur_page, + current_day=current_day, reaction_list=reaction_list, + cur_day_page_guesses=cur_day_page_guesses, monster=monster, num_pages=num_pages) + + +class PADleScrollView: + VIEW_TYPE = 'PADleScroll' + + @staticmethod + def embed(state: PADleScrollViewState): + fields = state.get_cur_fields() + return EmbedView( + EmbedMain( + title=f"**PADle #{state.current_day}**", + ), + embed_footer=embed_footer_with_state(state, text=state.get_pages_footer()), + embed_fields=fields) diff --git a/padle/view/personal_stats_view.py b/padle/view/personal_stats_view.py new file mode 100644 index 00000000..282961a9 --- /dev/null +++ b/padle/view/personal_stats_view.py @@ -0,0 +1,42 @@ +from discordmenu.embed.components import EmbedMain, EmbedThumbnail +from discordmenu.embed.view import EmbedView +from tsutils.menu.components.footers import embed_footer_with_state +from tsutils.menu.view.closable_embed import ClosableEmbedViewState +from tsutils.query_settings.query_settings import QuerySettings +from tsutils.tsubaki.links import MonsterImage +from tsutils.tsubaki.monster_header import MonsterHeader +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from dbcog.models.monster_model import MonsterModel + + +class PersonalStatsViewProps: + def __init__(self, query_settings: QuerySettings, username: str, played: int, win_rate: float, + cur_streak: int, max_streak: int, favorite_monster: "MonsterModel"): + self.query_settings = query_settings + self.username = username + self.played = played + self.win_rate = win_rate + self.cur_streak = cur_streak + self.max_streak = max_streak + self.monster = favorite_monster + + +class PersonalStatsView: + VIEW_TYPE = 'PADlePersonalStats' + + @classmethod + def embed(cls, state: ClosableEmbedViewState, props: PersonalStatsViewProps): + return EmbedView( + EmbedMain( + title=f"{props.username}'s PADle Stats", + description=(f"**Games Played**: {props.played}\n" + f"**Win Rate**: {props.win_rate:.2%}\n" + f"**Current Streak**: {props.cur_streak}\n" + f"**Max Streak**: {props.max_streak}\n" + f"**Favorite Guessed Monster**: {MonsterHeader.menu_title(props.monster).to_markdown()}"), + color=props.query_settings.embedcolor), + embed_thumbnail=EmbedThumbnail( + MonsterImage.icon(props.monster.monster_id)), + embed_footer=embed_footer_with_state(state, text="Click the X to close.")) diff --git a/padmonitor/__init__.py b/padmonitor/__init__.py index b2457b78..ce250b22 100644 --- a/padmonitor/__init__.py +++ b/padmonitor/__init__.py @@ -1,7 +1,9 @@ -from .padmonitor import * +from .padmonitor import PadMonitor +__red_end_user_data_statement__ = "No personal data is stored." -def setup(bot): + +async def setup(bot): n = PadMonitor(bot) - bot.add_cog(n) + bot.add_cog(n) if not __import__('asyncio').iscoroutinefunction(bot.add_cog) else await bot.add_cog(n) bot.loop.create_task(n.check_seen_loop()) diff --git a/padmonitor/info.json b/padmonitor/info.json new file mode 100644 index 00000000..4f4837a3 --- /dev/null +++ b/padmonitor/info.json @@ -0,0 +1,27 @@ +{ + "author": [ + "The Tsubaki Bot Team" + ], + "description": "Checks for and announces new monsters.", + "end_user_data_statement": "No personal data is stored.", + "install_msg": "Use [p]addnewchannel to set up a monster monitor.", + "short": "Announce new monsters", + "min_bot_version": "3.4.0", + "max_bot_version": "0.0.0", + "min_python_version": [ + 3, + 8, + 1 + ], + "hidden": false, + "disabled": false, + "required_cogs": { + "dbcog": "https://github.com/TsubakiBotPad/pad-cogs" + }, + "requirements": [ + "tsutils>=3.0.0" + ], + "tags": [ + "PAD" + ] +} diff --git a/padmonitor/padmonitor.py b/padmonitor/padmonitor.py index 5ef48188..d4b21e80 100644 --- a/padmonitor/padmonitor.py +++ b/padmonitor/padmonitor.py @@ -1,138 +1,208 @@ import asyncio +import logging +from io import BytesIO +from typing import Dict, List, Optional, TYPE_CHECKING -from redbot.core import commands, checks -from redbot.core.utils.chat_formatting import inline, box, pagify +import discord +from redbot.core import Config, checks, commands +from redbot.core.utils.chat_formatting import box, pagify +from tsutils.formatting import contains_ja -import rpadutils +if TYPE_CHECKING: + from dbcog.models.monster_model import MonsterModel + from dbcog.models.dungeon_model import DungeonModel + +logger = logging.getLogger('red.padbot-cogs.padmonitor') class PadMonitor(commands.Cog): + """Monitor PAD and announce new cards""" + def __init__(self, bot, *args, **kwargs): super().__init__(*args, **kwargs) self.bot = bot - self.settings = PadMonitorSettings("padmonitor") + + self.config = Config.get_conf(self, identifier=9407071702) + self.config.register_global(seen_jp_monsters=[], seen_na_monsters=[], + monster_announce_channels=[], + seen_jp_dungeons=[], seen_na_dungeons=[], + dungeon_announce_channels=[], ) + + async def red_get_data_for_user(self, *, user_id): + """Get a user's personal data.""" + data = "No data is stored for user with ID {}.\n".format(user_id) + return {"user_data.txt": BytesIO(data.encode())} + + async def red_delete_data_for_user(self, *, requester, user_id): + """Delete a user's personal data. + + No personal data is stored in this cog. + """ + return async def check_seen_loop(self): await self.bot.wait_until_ready() while self == self.bot.get_cog('PadMonitor'): try: - await self.check_seen() - except Exception as ex: - print("check seen loop caught exception " + str(ex)) + await self.check_seen_monsters() + await self.check_seen_dungeons() + except Exception: + logger.exception("Error in PadMonitor loop:") await asyncio.sleep(60 * 5) - async def check_seen(self): + async def check_seen_monsters(self): """Refresh the monster indexes.""" - pg_cog = self.bot.get_cog('Dadguide') - await pg_cog.wait_until_ready() - all_monsters = pg_cog.database.get_all_monsters(as_generator=False) - jp_monster_map = {m.monster_no: m for m in all_monsters if m.on_jp} - na_monster_map = {m.monster_no: m for m in all_monsters if m.on_na} - def process(existing, new_map, name): + def process(existing: List[int], # Will be mutated + new_map: Dict[int, "MonsterModel"], + region: str) -> Optional[str]: + if not existing: + # If everything is new, assume nothing is and log all current monsters as seen + logger.info('preloading %i monsters', len(new_map)) + existing.extend(new_map.keys()) + return None + + delta_set = set(new_map.keys()).difference(existing) + + if not delta_set: + # There are no new monsters + return None + + existing.extend(delta_set) + msg = f'New monsters added to {region}:' + for mid in sorted(delta_set): + m = new_map[mid] + msg += f'\n\t[{m.monster_id}] {m.unoverridden_name_en}' + if contains_ja(m.name_en) and m.name_en_override: + msg += f' ({m.name_en_override})' + return msg + + dbcog = self.bot.get_cog('DBCog') + await dbcog.wait_until_ready() + all_monsters = dbcog.database.get_all_monsters() + + jp_monster_map = {m.monster_id: m for m in all_monsters if m.on_jp} + async with self.config.seen_jp_monsters() as jp_seen: + if (jp_results := process(jp_seen, jp_monster_map, 'JP')): + for channel_id in await self.config.monster_announce_channels(): + await self.announce(channel_id, jp_results) + na_monster_map = {m.monster_id: m for m in all_monsters if m.on_na} + async with self.config.seen_na_monsters() as na_seen: + if (na_results := process(na_seen, na_monster_map, 'NA')): + for channel_id in await self.config.monster_announce_channels(): + await self.announce(channel_id, na_results) + + async def check_seen_dungeons(self): + """Refresh the dungeons indexes.""" + + def process(existing: List[int], # Will be mutated + new_map: Dict[int, "DungeonModel"]) -> Optional[str]: + # TODO: Don't repeat so much code here if not existing: - print('preloading', len(new_map)) + # If everything is new, assume nothing is and log all current dungeons as seen + logger.info('preloading %i dungeons', len(new_map)) existing.extend(new_map.keys()) - self.settings.save_settings() return None - existing_set = set(existing) - new_set = set(new_map.keys()) - delta_set = new_set - existing_set - - if delta_set: - existing.extend(delta_set) - self.settings.save_settings() - - msg = 'New monsters added to {}:'.format(name) - for m in [new_map[x] for x in delta_set]: - msg += '\n\tNo. {} {}'.format(m.monster_no, m.name_na) - if rpadutils.containsJp( - m.name_na) and m.name_na_override != m.name_na and m.name_na_override is not None: - msg += ' ({})'.format(m.name_na_override) - return msg - else: + delta_set = set(new_map.keys()).difference(existing) + + if not delta_set: + # There are no new dungeons return None - jp_results = process(self.settings.jp_seen(), jp_monster_map, 'JP') - na_results = process(self.settings.na_seen(), na_monster_map, 'NA') + existing.extend(delta_set) + msg = f'New dungeons added:' + for mid in sorted(delta_set): + msg += f'\n\t{new_map[mid].name_en}' + return msg + + dbcog = self.bot.get_cog('DBCog') + await dbcog.wait_until_ready() + all_dungeons = dbcog.database.dungeon.get_all_dungeons() - for msg in [jp_results, na_results]: - if not msg: - continue - for channel_id in self.settings.new_monster_channels(): - await self.announce(channel_id, msg) + dungeon_map = {m.dungeon_id: m for m in all_dungeons} # TODO: Find a way to test server + async with self.config.seen_jp_dungeons() as seen: + if (results := process(seen, dungeon_map)): + for channel_id in await self.config.dungeon_announce_channels(): + await self.announce(channel_id, results) - async def announce(self, channel_id, message): + async def announce(self, channel_id: int, message: str): try: - channel = self.bot.get_channel(int(channel_id)) + channel = self.bot.get_channel(channel_id) for page in pagify(message): await channel.send(box(page)) except Exception as ex: - print('failed to send message to', channel_id, ' : ', ex) + logger.exception(f'Failed to send message to {channel_id}:') - @commands.group() - @checks.mod_or_permissions(manage_guild=True) + @commands.group(aliases=['pdm']) async def padmonitor(self, ctx): """PAD info monitoring""" pass @padmonitor.command() + @checks.is_owner() + async def reload(self, ctx): + """Update PDM channels""" + async with ctx.typing(): + await self.check_seen_monsters() + await self.check_seen_dungeons() + await ctx.tick() + + @padmonitor.group(name='monsters', aliases=['monster']) + async def pdm_monsters(self, ctx): + """New monster announcement""" + pass + + @pdm_monsters.command(name='addchannel') @commands.guild_only() @checks.mod_or_permissions(manage_guild=True) - async def addnewchannel(self, ctx): + async def pdm_m_addchannel(self, ctx, channel: discord.TextChannel = None): """Sets announcements for the current channel.""" - self.settings.add_new_monster_channel(ctx.channel.id) - await ctx.send(inline('done')) + channel = channel or ctx.channel + async with self.config.monster_announce_channels() as announce_channels: + if channel.id in announce_channels: + return await ctx.send(f"{channel} is already a monster announcement channel.") + announce_channels.append(channel.id) + await ctx.tick() + + @pdm_monsters.command(name='rmchannel', aliases=['removechannel']) + @commands.guild_only() + @checks.mod_or_permissions(manage_guild=True) + async def pdm_m_rmchannel(self, ctx, channel: discord.TextChannel = None): + """Removes announcements for the current channel.""" + channel = channel or ctx.channel + async with self.config.monster_announce_channels() as announce_channels: + if channel.id not in announce_channels: + return await ctx.send(f"{channel} is not a monster announcement channel.") + announce_channels.remove(channel.id) + await ctx.tick() + + @padmonitor.group(name='dungeons', aliases=['dungeon']) + async def pdm_dungeons(self, ctx): + """New dungeon announcement""" + pass - @padmonitor.command() + @pdm_dungeons.command(name='addchannel') + @commands.guild_only() + @checks.mod_or_permissions(manage_guild=True) + async def pdm_d_addchannel(self, ctx, channel: discord.TextChannel = None): + """Sets announcements for the current channel.""" + channel = channel or ctx.channel + async with self.config.dungeon_announce_channels() as announce_channels: + if channel.id in announce_channels: + return await ctx.send(f"{channel} is already a dungeon announcement channel.") + announce_channels.append(channel.id) + await ctx.tick() + + @pdm_dungeons.command(name='rmchannel', aliases=['removechannel']) @commands.guild_only() @checks.mod_or_permissions(manage_guild=True) - async def rmnewchannel(self, ctx): + async def pdm_d_rmchannel(self, ctx, channel: discord.TextChannel = None): """Removes announcements for the current channel.""" - self.settings.rm_new_monster_channel(ctx.channel.id) - await ctx.send(inline('done')) - - -class PadMonitorSettings(rpadutils.CogSettings): - def make_default_settings(self): - config = { - 'jp_seen_ids': [], - 'na_seen_ids': [], - 'new_monster_channels': [], - } - return config - - def jp_seen(self): - return self.bot_settings['jp_seen_ids'] - - def na_seen(self): - return self.bot_settings['na_seen_ids'] - - def add_jp_seen(self, monster_id: int): - ids = self.jp_seen() - if monster_id not in ids: - ids.append(monster_id) - self.save_settings() - - def add_na_seen(self, monster_id: int): - ids = self.na_seen() - if monster_id not in ids: - ids.append(monster_id) - self.save_settings() - - def new_monster_channels(self): - return self.bot_settings['new_monster_channels'] - - def add_new_monster_channel(self, channel_id): - channels = self.new_monster_channels() - if channel_id not in channels: - channels.append(channel_id) - self.save_settings() - - def rm_new_monster_channel(self, channel_id): - channels = self.new_monster_channels() - if channel_id in channels: - channels.remove(channel_id) - self.save_settings() + channel = channel or ctx.channel + async with self.config.dungeon_announce_channels() as announce_channels: + if channel.id not in announce_channels: + return await ctx.send(f"{channel} is not a dungeon announcement channel.") + announce_channels.remove(channel.id) + await ctx.tick() diff --git a/padsearch/__init__.py b/padsearch/__init__.py deleted file mode 100644 index af5597eb..00000000 --- a/padsearch/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .padsearch import * - - -def setup(bot): - bot.add_cog(PadSearch(bot)) diff --git a/padsearch/padsearch.py b/padsearch/padsearch.py deleted file mode 100644 index ae8fd31e..00000000 --- a/padsearch/padsearch.py +++ /dev/null @@ -1,806 +0,0 @@ -import json -import re -from fnmatch import fnmatch - -from ply import lex -from redbot.core import checks -from redbot.core import commands -from redbot.core.bot import Red -from redbot.core.utils.chat_formatting import box, pagify - -from rpadutils import rpadutils - -HELP_MSG = """ -{0.prefix}search - -To get more than 10 results, include the word 'all' in your search. - -Colors can be any of: - fire water wood light dark -Additionally, orb colors can be any of: - heart jammer poison mortal and bomb - -Options which take multiple colors should be comma-separated. - -Single instance filters -* hp(n) : Max HP >= n -* atk(n) : Max ATK >= n -* rcv(n) : Max RCV >= n -* weighted(n) : Weighted stat >= n -* cd(n) : Min cd <= n -* farmable : Obtainable outside REM -* haste(n) : Skill cds reduced by n -* inheritable : Can be inherited -* shuffle : Board shuffle (aka refresh) -* unlock : Orb unlock -* delay(n) : Delay enemies by n -* attabsorb : Attribute Absorb shield null -* absorbnull : Damage Abasorb shield null -* combo(n) : Increase combo count by n -* shield(n) : Reduce damage taken by n% -* resolve : Leader skill where you survive when HP reduced to 0 - -Multiple instance filters -* active(str) : Active skill name/description -* board(colors,) : Board change to a comma-sep list of colors -* color(color) : Primary monster color -* column(color) : Creates a column of a color -* hascolor(color) : Primary or secondary monster color -* leader(str) : Leader skill description -* name(str) : Monster name -* row(color) : Creates a row of a color -* type(str) : Monster type -* convert(c1, c2) : Convert from color 1 to color 2, accepts any as entry as well -""" - - -@rpadutils.timeout_after(1) -def filt_timeout(filts, ms): - for f in filts: - ms = [m for m in ms if f(m)] - return ms - - -COLORS = [ - 'fire', - 'water', - 'wood', - 'light', - 'dark', -] - -TYPES = [ - "attacker", - "awoken", - "balance", - "devil", - "dragon", - "enhance", - "evolve", - "god", - "healer", - "machine", - "physical", - "protected", - "vendor", -] - -ORB_TYPES = [ - 'any', - 'fire', - 'water', - 'wood', - 'light', - 'dark', - 'heal', - 'jammer', - 'poison', - 'mortal', -] - - -def assert_color(value): - value = replace_named_color(value) - if value not in COLORS: - raise commands.UserFeedbackCheckFailure( - 'Unexpected color {}, expected one of {}'.format(value, COLORS)) - return value - - -def assert_orbcolor(value): - value = replace_named_color(value) - if value not in ORB_TYPES: - raise commands.UserFeedbackCheckFailure( - 'Unexpected orb {}, expected one of {}'.format(value, ORB_TYPES)) - return value - - -def assert_orbcolors(values): - return [assert_orbcolor(c) for c in values] - - -def split_csv_orbcolors(value): - parts = [p.strip() for p in value.split(',')] - return assert_orbcolors(parts) - - -COLOR_REPLACEMENTS = { - 'red': 'fire', - 'r': 'fire', - 'blue': 'water', - 'b': 'water', - 'green': 'wood', - 'g': 'wood', - 'l': 'light', - 'd': 'dark', - 'heart': 'heal', - 'h': 'heal', - 'p': 'poison', - 'mortal': 'mortalpoison', - 'j': 'jammer', - 'o': 'bomb', -} - - -def replace_named_color(color: str): - color = color.lower().strip() - return COLOR_REPLACEMENTS.get(color, color) - - -def replace_colors_in_text(text: str): - text = text.replace('red', 'fire') - text = text.replace('blue', 'water') - text = text.replace('green', 'wood') - text = text.replace('heart', 'heal') - return text - - -def clean_name(txt, name): - return txt.replace(name, '').strip('() ') - - -def re_clean_name(txt, name): - return clean_name(txt, name)[2:-1] - - -def board_filter(colors): - def fn(m, colors=colors): - # Copy for safety - colors = list(colors) - m_colors = list(m.search.board_change) - - if len(m_colors) != len(colors): - return False - - any_values = 0 - for c in colors: - if c == 'any': - any_values += 1 - else: - if c in m_colors: - m_colors.remove(c) - else: - return False - - # Check remaining anys - return any_values == len(m_colors) - - return fn - - -class PadSearchLexer(object): - tokens = [ - 'ACTIVE', - 'ALL', - 'BOARD', - 'CD', - 'COLOR', - 'COLUMN', - 'FARMABLE', - 'HASCOLOR', - 'HASTE', - 'INHERITABLE', - 'LEADER', - 'NAME', - 'ROW', - 'TYPE', - 'SHUFFLE', - 'UNLOCK', - 'RESOLVE', - 'DELAY', - 'REMOVE', - 'CONVERT', - 'COMBO', - 'ABSORBNULL', - 'ATTABSORB', - 'SHIELD', - 'HP', - 'ATK', - 'RCV', - 'WEIGHTED', - 'REACTIVE', - 'GACTIVE', - 'RELEADER', - 'GLEADER', - ] - - def t_ACTIVE(self, t): - r'\bactive\(.+?\)' - t.value = clean_name(t.value, 'active') - t.value = replace_colors_in_text(t.value) - return t - - def t_REACTIVE(self, t): - r"reactive\(r'.+?'\)" - t.value = re_clean_name(t.value, 'reactive') - t.value = replace_colors_in_text(t.value) - return t - - def t_GACTIVE(self, t): - r"gactive\(.+?\)" - t.value = clean_name(t.value, 'gactive') - t.value = replace_colors_in_text(t.value) - return t - - def t_ALL(self, t): - r'all(\(\))?' - return t - - def t_BOARD(self, t): - r'board\([a-zA-z, ]+\)' - t.value = clean_name(t.value, 'board') - t.value = replace_colors_in_text(t.value) - return t - - def t_CD(self, t): - r'cd\(\d+\)' - t.value = clean_name(t.value, 'cd') - t.value = int(t.value) - return t - - def t_COLOR(self, t): - r'color\([a-zA-z]+\)' - t.value = clean_name(t.value, 'color') - t.value = replace_colors_in_text(t.value) - return t - - def t_COLUMN(self, t): - r'column\([a-zA-z]+\)' - t.value = clean_name(t.value, 'column') - t.value = replace_colors_in_text(t.value) - return t - - def t_FARMABLE(self, t): - r'farmable(\(\))?' - return t - - def t_HASCOLOR(self, t): - r'hascolor\([a-zA-z]+\)' - t.value = clean_name(t.value, 'hascolor') - t.value = replace_colors_in_text(t.value) - return t - - def t_HASTE(self, t): - r'haste\(\d+\)' - t.value = clean_name(t.value, 'haste') - t.value = int(t.value) - return t - - def t_INHERITABLE(self, t): - r'inheritable(\(\))?' - return t - - def t_LEADER(self, t): - r'\bleader\(.+?\)' - t.value = clean_name(t.value, 'leader') - t.value = replace_colors_in_text(t.value) - return t - - def t_RELEADER(self, t): - r"releader\(r'.+?'\)" - t.value = re_clean_name(t.value, 'releader') - t.value = replace_colors_in_text(t.value) - return t - - def t_GLEADER(self, t): - r"gleader\(.+?\)" - t.value = clean_name(t.value, 'gleader') - t.value = replace_colors_in_text(t.value) - return t - - def t_NAME(self, t): - r'name\([a-zA-Z0-9 ]+\)' - t.value = clean_name(t.value, 'name') - return t - - def t_ROW(self, t): - r'row\([a-zA-Z]+\)' - t.value = clean_name(t.value, 'row') - t.value = replace_colors_in_text(t.value) - return t - - def t_SHUFFLE(self, t): - r'shuffle(\(\))?' - return t - - def t_TYPE(self, t): - r'type\([a-zA-z]+\)' - t.value = clean_name(t.value, 'type') - return t - - def t_UNLOCK(self, t): - r'unlock(\(\))?' - return t - - def t_RESOLVE(self, t): - r'resolve(\(\))?' - return t - - def t_DELAY(self, t): - r'delay\(\d+\)' - t.value = clean_name(t.value, 'delay') - t.value = int(t.value) - return t - - def t_REMOVE(self, t): - r'remove\([a-zA-Z0-9 ]+\)' - t.value = t.value.replace('remove', '').strip('()') - return t - - def t_CONVERT(self, t): - r'convert\([a-zA-z, ]+\)' - t.value = clean_name(t.value, 'convert') - i = t.value.split(',') - i[0] = replace_named_color(i[0]).lower() - i[1] = replace_named_color(i[1]).lower() - t.value = i - return t - - def t_COMBO(self, t): - r'combo\(\d+\)' - t.value = clean_name(t.value, 'combo') - t.value = int(t.value) - return t - - def t_ABSORBNULL(self, t): - r'absorbnull(\(\))?' - return t - - def t_ATTABSORB(self, t): - r'attabsorb(\(\))?' - return t - - def t_SHIELD(self, t): - r'shield\(\d+[\d%]\)' - t.value = clean_name(t.value, 'shield') - t.value = t.value.strip('%') - t.value = int(t.value) - return t - - def t_ATK(self, t): - r'atk\(\d+\)' - t.value = clean_name(t.value, 'atk') - t.value = int(t.value) - return t - - def t_HP(self, t): - r'hp\(\d+\)' - t.value = clean_name(t.value, 'hp') - t.value = int(t.value) - return t - - def t_RCV(self, t): - r'rcv\(\d+\)' - t.value = clean_name(t.value, 'rcv') - t.value = int(t.value) - return t - - def t_WEIGHTED(self, t): - r'weighted\(\d+\)' - t.value = clean_name(t.value, 'weighted') - t.value = int(t.value) - return t - - t_ignore = ' \t\n' - - def t_error(self, t): - raise commands.UserFeedbackCheckFailure("Unknown text '%s'" % (t.value,)) - - def build(self, **kwargs): - # pass debug=1 to enable verbose output - self.lexer = lex.lex(module=self) - return self.lexer - - -class SearchConfig(object): - - def __init__(self, lexer): - self.all = False - self.cd = None - self.farmable = None - self.haste = None - self.inheritable = None - self.shuffle = None - self.unlock = None - self.resolve = None - self.delay = None - self.combo = None - self.absorbnull = None - self.attabsorb = None - self.shield = None - self.hp = None - self.atk = None - self.rcv = None - self.weighted = None - - self.active = [] - self.reactive = [] - self.gactive = [] - self.board = [] - self.column = [] - self.color = [] - self.hascolor = [] - self.leader = [] - self.releader = [] - self.gleader = [] - self.name = [] - self.row = [] - self.types = [] - self.remove = [] - self.convert = [] - for tok in iter(lexer.token, None): - type = tok.type - value = tok.value - self.all |= type == 'ALL' - self.cd = self.setIfType('CD', type, self.cd, value) - self.farmable = self.setIfType('FARMABLE', type, self.farmable, value) - self.haste = self.setIfType('HASTE', type, self.haste, value) - self.inheritable = self.setIfType('INHERITABLE', type, self.inheritable, value) - self.shuffle = self.setIfType('SHUFFLE', type, self.shuffle, value) - self.unlock = self.setIfType('UNLOCK', type, self.unlock, value) - self.resolve = self.setIfType('RESOLVE', type, self.resolve, value) - self.delay = self.setIfType('DELAY', type, self.delay, value) - self.combo = self.setIfType('COMBO', type, self.combo, value) - self.absorbnull = self.setIfType('ABSORBNULL', type, self.absorbnull, value) - self.attabsorb = self.setIfType('ATTABSORB', type, self.attabsorb, value) - self.shield = self.setIfType('SHIELD', type, self.shield, value) - self.atk = self.setIfType('ATK', type, self.atk, value) - self.hp = self.setIfType('HP', type, self.hp, value) - self.rcv = self.setIfType('RCV', type, self.rcv, value) - self.weighted = self.setIfType('WEIGHTED', type, self.weighted, value) - - if type == 'ACTIVE': - self.active.append(value) - if type == 'REACTIVE': - self.reactive.append(value) - if type == 'GACTIVE': - self.gactive.append(value) - if type == 'BOARD': - self.board.append(split_csv_orbcolors(value)) - if type == 'COLOR': - self.color.append(assert_color(value)) - if type == 'COLUMN': - self.column.append(assert_orbcolor(value)) - if type == 'HASCOLOR': - self.hascolor.append(assert_color(value)) - if type == 'LEADER': - self.leader.append(value) - if type == 'RELEADER': - self.releader.append(value) - if type == 'GLEADER': - self.gleader.append(value) - if type == 'NAME': - self.name.append(value) - if type == 'ROW': - self.row.append(assert_orbcolor(value)) - if type == 'TYPE': - if value not in TYPES: - raise commands.UserFeedbackCheckFailure( - 'Unexpected type {}, expected one of {}'.format(value, TYPES)) - self.types.append(value) - if type == 'REMOVE': - self.remove.append(value) - if type == 'CONVERT': - self.convert.append(value) - - self.filters = list() - self.re_filters = list() - self.gl_filters = list() - self.regeces = list() - self.globs = list() - - # Single - if self.cd: - self.filters.append(lambda m: m.search.active_min and m.search.active_min <= self.cd) - - if self.farmable: - self.filters.append(lambda m: m.farmable_evo) - - if self.haste: - text = "charge allies' skill by {}".format(self.haste) - self.filters.append(lambda m, t=text: t in m.search.active_desc) - - if self.inheritable: - self.filters.append(lambda m: m.is_inheritable) - - if self.shuffle: - text = 'replace all' - self.filters.append(lambda m, t=text: t in m.search.active_desc) - - if self.unlock: - text = 'unlock all orbs' - self.filters.append(lambda m, t=text: t in m.search.active_desc) - - if self.resolve: - text = 'may survive when' - self.filters.append(lambda m, t=text: t in m.search.leader) - - if self.delay: - text = 'delay enemies for {}'.format(self.delay) - self.filters.append(lambda m, t=text: t in m.search.active_desc) - - if self.combo: - text = 'increase combo count by {}'.format(self.combo) - self.filters.append(lambda m, t=text: t in m.search.active_desc) - - if self.convert: - text_from = self.convert[0][0] - text_to = self.convert[0][1] - self.filters.append(lambda m, - tt=text_to, - tf=text_from: - [tt] in m.search.orb_convert.values() if text_from == 'any' else - (tf in m.search.orb_convert.keys() if text_to == 'any' else - (tf in m.search.orb_convert.keys() and - tt in m.search.orb_convert[tf]))) - - if self.absorbnull: - text = 'damage absorb shield' - self.filters.append(lambda m, t=text: t in m.search.active_desc) - - if self.attabsorb: - text = 'att. absorb shield' - self.filters.append(lambda m, t=text: t in m.search.active_desc) - - if self.shield: - text = 'damage taken by {}%'.format(self.shield) - self.filters.append(lambda m, t=text: t in m.search.active_desc) - - if self.atk: - self.filters.append(lambda m: m.search.atk and m.search.atk >= self.atk) - - if self.hp: - self.filters.append(lambda m: m.search.hp and m.search.hp >= self.hp) - - if self.rcv: - self.filters.append(lambda m: m.search.rcv and m.search.rcv >= self.rcv) - - if self.weighted: - self.filters.append( - lambda m: m.search.weighted_stats and m.search.weighted_stats >= self.weighted) - - # Multiple - if self.reactive: - filters = [] - for ft in self.reactive: - text = ft.lower() - filters.append(lambda m, t=text: re.search(t, m.search.active)) - self.regeces.append(text) - self.re_filters.extend(filters) - - if self.active: - filters = [] - for ft in self.active: - text = "*" + ft.lower() + "*" - filters.append(lambda m, t=text: fnmatch(m.search.active, t)) - self.globs.append(text) - self.gl_filters.extend(filters) - - if self.board: - filters = [] - for colors in self.board: - filters.append(board_filter(colors)) - self.filters.append(self.or_filters(filters)) - - if self.color: - filters = [] - for ft in self.color: - text = ft.lower() - filters.append(lambda m, c=text: c in m.search.color) - self.filters.append(self.or_filters(filters)) - - if self.column: - filters = [] - for ft in self.column: - text = ft.lower() - if text == 'any': - filters.append(lambda m: m.search.column_convert) - else: - filters.append(lambda m, t=text: t in m.search.column_convert) - self.filters.append(self.or_filters(filters)) - - if self.hascolor: - filters = [] - for ft in self.hascolor: - text = ft.lower() - filters.append(lambda m, c=text: c in m.search.hascolor) - self.filters.append(self.or_filters(filters)) - - if self.releader: - filters = [] - for ft in self.releader: - text = ft.lower() - filters.append(lambda m, t=text: re.search(t, m.search.leader)) - self.regeces.append(text) - self.re_filters.extend(filters) - - if self.leader: - filters = [] - for ft in self.leader: - text = "*" + ft.lower() + "*" - filters.append(lambda m, t=text: fnmatch(m.search.leader, t)) - self.globs.append(text) - self.gl_filters.extend(filters) - - if self.name: - filters = [] - for ft in self.name: - text = ft.lower() - filters.append(lambda m, t=text: t in m.search.name) - self.filters.append(self.or_filters(filters)) - - if self.row: - filters = [] - for ft in self.row: - text = ft.lower() - if text == 'any': - filters.append(lambda m: m.search.row_convert) - else: - filters.append(lambda m, t=text: t in m.search.row_convert) - self.filters.append(self.or_filters(filters)) - - if self.types: - filters = [] - for ft in self.types: - text = ft.lower() - filters.append(lambda m, t=text: t in m.search.types) - self.filters.append(self.or_filters(filters)) - - if self.remove: - filters = [] - for ft in self.remove: - text = ft.lower() - filters.append(lambda m, t=text: t not in m.search.name) - self.filters.append(self.or_filters(filters)) - if not self.filters and not self.re_filters and not self.gl_filters: - raise commands.UserFeedbackCheckFailure('You need to specify at least one filter') - - def check_filters(self, m): - for f in self.filters: - if not f(m): - return False - return True - - async def check_re_filters(self, ms, ctx): - try: - return filt_timeout(self.re_filters, ms) - except TimeoutError: - print("Timeout with patttern: \"{}\" by user {} ({})".format( - '", "'.join(self.regeces), ctx.author.name, ctx.author.id)) - raise commands.UserFeedbackCheckFailure("Regex took too long to compile. Stop trying to break the bot") - except re.error as e: - raise commands.UserFeedbackCheckFailure("Regex search threw error '{}'".format(e.msg)) - - async def check_glob_filters(self, ms, ctx): - try: - return filt_timeout(self.gl_filters, ms) - except TimeoutError: - print("Timeout with patttern: \"{}\" by user {} ({})".format( - '", "'.join(self.globs), ctx.author.name, ctx.author.id)) - raise commands.UserFeedbackCheckFailure("Glob took too long to compile. Stop trying to break the bot") - - def or_filters(self, filters): - def fn(m, filters=filters): - for f in filters: - if f(m): - return True - return False - - return fn - - def setIfType(self, expected_type, given_type, current_value, new_value): - if expected_type != given_type: - return current_value - if current_value is not None: - raise commands.UserFeedbackCheckFailure('You set {} more than once'.format(given_type)) - return new_value - - -class PadSearch(commands.Cog): - """PAD data searching.""" - - def __init__(self, bot: Red, *args, **kwargs): - super().__init__(*args, **kwargs) - self.bot = bot - - @commands.command() - async def helpsearch(self, ctx): - """Help info for the search command.""" - await ctx.author.send(box(HELP_MSG.format(ctx))) - - @commands.command() - async def search(self, ctx, *, filter_spec: str): - """Searches for monsters based on a filter you specify. - Use [p]helpsearch for more info. - """ - try: - config = self._make_search_config(filter_spec) - except Exception as ex: - # Try to correct for missing closing tag - try: - config = self._make_search_config(filter_spec + ')') - except: - # If it still failed, raise the original exception - raise ex - dg_cog = self.bot.get_cog('Dadguide') - if dg_cog == None: - await ctx.send("Dadguide cog not loaded.") - return - monsters = dg_cog.database.get_all_monsters() - - rmvGemFilter = self._make_search_config('remove( gem)') - monsters = list(filter(rmvGemFilter.check_filters, monsters)) - - matched_monsters = [m for m in monsters if config.check_filters(m)] - # Running regeces/globs on a timer - matched_monsters = await config.check_re_filters(matched_monsters, ctx) - matched_monsters = await config.check_glob_filters(matched_monsters, ctx) - - # Removing entry with names that have gems in it - - matched_monsters.sort(key=lambda m: m.monster_no_na, reverse=True) - - msg = 'Matched {} monsters'.format(len(matched_monsters)) - dm_required = False - if len(matched_monsters) == len(monsters): - await ctx.send("All monsters were matched. Try with a more specific query.") - return - if len(matched_monsters) > 10: - if not config.all: - msg += " (limited to 10, use 'all' to get more)" - matched_monsters = matched_monsters[0:10] - else: - dm_required = True - header = msg - - if len(matched_monsters) > 200: - msg += " (limited to 200)" - matched_monsters = matched_monsters[0:200] - - for m in matched_monsters: - msg += '\n\tNo. {} {}'.format(m.monster_no_na, m.name_na) - - if dm_required: - header += '\nList too long to display; sent via DM' - await ctx.send(box(header)) - for page in pagify(msg): - await ctx.author.send(box(page)) - else: - await ctx.send(box(msg)) - - def _make_search_config(self, input): - lexer = PadSearchLexer().build() - lexer.input(input) - return SearchConfig(lexer) - - @commands.command() - @checks.is_owner() - async def debugsearch(self, ctx, *, query): - padinfo_cog = self.bot.get_cog('PadInfo') - m, err, debug_info = padinfo_cog.findMonster(query) - - if m is None: - await ctx.send(box('No match: ' + err)) - return - - await ctx.send(box(json.dumps(m.search, indent=2, default=lambda o: o.__dict__))) diff --git a/padvision/__init__.py b/padvision/__init__.py deleted file mode 100644 index 0f721afb..00000000 --- a/padvision/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .padvision import * - - -def setup(bot): - bot.add_cog(PadVision(bot)) diff --git a/pipelineui/__init__.py b/pipelineui/__init__.py new file mode 100644 index 00000000..8fe3bb2c --- /dev/null +++ b/pipelineui/__init__.py @@ -0,0 +1,8 @@ +from .pipelineui import PipelineUI + +__red_end_user_data_statement__ = "No personal data is stored." + + +async def setup(bot): + pdb = PipelineUI(bot) + bot.add_cog(pdb) if not __import__('asyncio').iscoroutinefunction(bot.add_cog) else await bot.add_cog(pdb) diff --git a/pipelineui/info.json b/pipelineui/info.json new file mode 100644 index 00000000..c1099db5 --- /dev/null +++ b/pipelineui/info.json @@ -0,0 +1,26 @@ +{ + "author": [ + "The Tsubaki Bot Team" + ], + "description": "Dungeon scraper and general database manager.", + "end_user_data_statement": "No personal data is stored.", + "install_msg": "Use [p]padguidedb to get started", + "short": "Dungeon data scraper", + "min_bot_version": "3.4.0", + "max_bot_version": "0.0.0", + "min_python_version": [ + 3, + 8, + 1 + ], + "hidden": false, + "disabled": false, + "required_cogs": {}, + "requirements": [ + "tsutils>=3.0.0", + "pymysql" + ], + "tags": [ + "PAD" + ] +} diff --git a/pipelineui/pipelineui.py b/pipelineui/pipelineui.py new file mode 100644 index 00000000..65ca48da --- /dev/null +++ b/pipelineui/pipelineui.py @@ -0,0 +1,478 @@ +import asyncio +import json +import logging +from io import BytesIO + +import discord +import pymysql +from redbot.core import checks, commands +from redbot.core.utils.chat_formatting import box, inline, pagify +from tsutils.cog_settings import CogSettings +from tsutils.user_interaction import send_repeated_consecutive_messages + +logger = logging.getLogger('red.padbot-cogs.pipelineui') + + +def is_padguidedb_admin_check(ctx): + is_owner = ctx.author.id in ctx.bot.owner_ids + return is_owner or ctx.bot.get_cog("PipelineUI").settings.checkAdmin(ctx.author.id) + + +def is_padguidedb_admin(): + return commands.check(is_padguidedb_admin_check) + + +class PipelineUI(commands.Cog): + """UI to the Tsubaki database""" + + def __init__(self, bot, *args, **kwargs): + super().__init__(*args, **kwargs) + self.bot = bot + self.settings = PadGuideDbSettings("padguidedb") + + self.queue_size = 0 + self.pipeline_lock = asyncio.Lock() + self.dungeon_lock = asyncio.Lock() + self.extract_images_lock = asyncio.Lock() + self.dungeon_load_lock = asyncio.Lock() + + async def red_get_data_for_user(self, *, user_id): + """Get a user's personal data.""" + data = "No data is stored for user with ID {}.\n".format(user_id) + return {"user_data.txt": BytesIO(data.encode())} + + async def red_delete_data_for_user(self, *, requester, user_id): + """Delete a user's personal data. + + No personal data is stored in this cog. + """ + return + + def get_cursor(self): + with open(self.settings.configFile(), 'r') as db_config: + return self.connect(json.load(db_config)).cursor() + + def connect(self, db_config): + return pymysql.connect(host=db_config['host'], + user=db_config['user'], + password=db_config['password'], + db=db_config['db'], + charset=db_config['charset'], + cursorclass=pymysql.cursors.DictCursor, + autocommit=True) + + @commands.group(aliases=['pdb', 'pui']) + @is_padguidedb_admin() + async def pipelineui(self, context): + """PadGuide database manipulation.""" + + @pipelineui.command() + @checks.is_owner() + async def addadmin(self, ctx, user: discord.Member): + """Adds a user to the padguide db admin""" + self.settings.addAdmin(user.id) + await ctx.tick() + + @pipelineui.command() + @checks.is_owner() + async def rmadmin(self, ctx, user): + """Removes a user from the padguide db admin""" + try: + u = await commands.MemberConverter().convert(ctx, user) + self.settings.rmAdmin(u.id) + except commands.BadArgument as e: + try: + u = int(user) + self.settings.rmAdmin(u) + except ValueError: + await ctx.send(inline("Invalid user id.")) + return + await ctx.tick() + + @pipelineui.command() + @checks.is_owner() + async def setconfigfile(self, ctx, *, config_file): + """Set the database config file.""" + self.settings.setConfigFile(config_file) + await ctx.tick() + + @pipelineui.command() + async def searchdungeon(self, ctx, *, search_text): + """Search for a dungeon via its jp or na name""" + search_text = '%{}%'.format(search_text) + with self.get_cursor() as cursor: + sql = ('SELECT dungeon_id, name_en, name_ja, visible FROM dungeons' + ' WHERE LOWER(name_en) LIKE %s OR LOWER(name_ja) LIKE %s' + ' ORDER BY dungeon_id DESC LIMIT 20') + cursor.execute(sql, [search_text, search_text]) + results = list(cursor.fetchall()) + msg = 'Results\n' + json.dumps(results, indent=2, ensure_ascii=False) + await ctx.send(inline(sql)) + for page in pagify(msg): + await ctx.send(box(page)) + + @pipelineui.command() + async def subdungeonlist(self, ctx, dungeon_id): + """Show the subdungeon ids of a dungeon""" + with self.get_cursor() as cursor: + cursor.execute('SELECT name_en FROM dungeons' + ' WHERE dungeon_id = %s', (dungeon_id,)) + dg_name = next(iter(cursor.fetchall()), {'name_en': None})['name_en'] + if dg_name is None: + return await ctx.send(f"No dungeon found with id {dungeon_id}") + + cursor.execute('SELECT sub_dungeon_id, name_en FROM sub_dungeons' + ' WHERE dungeon_id = %s' + ' ORDER BY sub_dungeon_id', (dungeon_id,)) + results = list(cursor.fetchall()) + msg = f'{dg_name}: {dungeon_id}\n\t' + '\n\t'.join(f"{row['name_en']}: {row['sub_dungeon_id']}" + for row in results) + for page in pagify(msg): + await ctx.send(box(page)) + + @pipelineui.command() + async def searchsubdungeons(self, ctx, *, search_text): + """Show the subdungeon ids of all matching dungeons""" + search_text = f"%{search_text}%" + with self.get_cursor() as cursor: + cursor.execute('SELECT dungeon_id, name_en FROM dungeons' + ' WHERE LOWER(name_en) LIKE %s OR LOWER(name_ja) LIKE %s' + ' ORDER BY dungeon_id LIMIT 20', (search_text, search_text)) + dgs = cursor.fetchall() + if not dgs: + return await ctx.send(f"No dungeons found") + + msg = [] + for dg in dgs: + cursor.execute('SELECT sub_dungeon_id, name_en FROM sub_dungeons' + ' WHERE dungeon_id = %s' + ' ORDER BY sub_dungeon_id', (dg['dungeon_id'],)) + results = list(cursor.fetchall()) + msg.append(f"{dg['name_en']} - {dg['dungeon_id']}\n\t" + + '\n\t'.join(f"{row['name_en']} - {row['sub_dungeon_id']}" + for row in results)) + await ctx.send_interactive(map(box, pagify('\n\n'.join(msg), delims=['\n\n']))) + + @pipelineui.command() + @is_padguidedb_admin() + async def loaddungeon(self, ctx, server: str, dungeon_id: int, dungeon_floor_id: int, queues: int = 1): + if queues > 5: + await ctx.send("You must send less than 5 queues.") + return + elif self.queue_size + queues > 60: + await ctx.send("The size of the queue cannot exceed 60. It is currently {}.".format(self.queue_size)) + return + elif not self.settings.hasUserInfo(server): + await ctx.send("There is no account associated with server '{}'.".format(server.upper())) + return + + self.queue_size += queues + if queues == 1: + await ctx.send(inline('Queueing load in slot {}'.format(self.queue_size))) + else: + await ctx.send( + inline('Queueing loads in slots {}-{}'.format(self.queue_size - queues + 1, self.queue_size))) + + event_loop = asyncio.get_event_loop() + for queue in range(queues): + event_loop.create_task(self.do_dungeon_load(ctx, server.upper(), dungeon_id, dungeon_floor_id)) + + async def do_dungeon_load(self, ctx, server, dungeon_id, dungeon_floor_id): + async with self.dungeon_load_lock: + process = await asyncio.create_subprocess_exec( + self.settings.pythonExecutable(), + self.settings.dungeonScriptFile(), + '--db_config={}'.format(self.settings.configFile()), + '--server={}'.format(server), + '--dungeon_id={}'.format(dungeon_id), + '--floor_id={}'.format(dungeon_floor_id), + '--user_uuid={}'.format(self.settings.userUuidFor(server)), + '--user_intid={}'.format(self.settings.userIntidFor(server)), + '--stream_safe', + + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + + if b"Traceback" in stderr: + logger.error("Dungeon Load Error:\n{}\n\n{}".format(stdout.decode(), stderr.decode())) + await send_repeated_consecutive_messages(ctx, inline( + 'Load for {} {} {} failed'.format(server, dungeon_id, dungeon_floor_id))) + else: + await send_repeated_consecutive_messages(ctx, inline( + 'Load for {} {} {} finished'.format(server, dungeon_id, dungeon_floor_id))) + self.queue_size -= 1 + + @pipelineui.command() + @is_padguidedb_admin() + async def olddungeondrops(self, ctx, dungeon_id: int, dungeon_floor_id: int): + with self.get_cursor() as cursor: + sql = ("SELECT stage, drop_monster_id, COUNT(*) AS count" + " FROM wave_data" + " WHERE dungeon_id = {} AND floor_id = {}" + " GROUP BY 1, 2" + " ORDER BY 1, 2").format(int(dungeon_id), int(dungeon_floor_id)) + cursor.execute(sql) + results = list(cursor.fetchall()) + msg = 'stage,drop_monster_id,count' + for row in results: + msg += '\n{},{},{}'.format(row['stage'], row['drop_monster_id'], row['count']) + for page in pagify(msg): + await ctx.send(box(page)) + + @pipelineui.command() + @is_padguidedb_admin() + async def dungeondrops(self, ctx, dungeon_id: int): + with self.get_cursor() as cursor: + sql = ("SELECT `floor_id`, COUNT(DISTINCT `entry_id`)" + " FROM `dadguide`.`wave_data`" + " WHERE `dungeon_id` = {}" + " GROUP BY `floor_id`" + " ORDER BY `wave_data`.`floor_id` DESC;").format(dungeon_id) + cursor.execute(sql) + results1 = list(cursor.fetchall()) + + sql = ("SELECT `floor_id`, `drop_monster_id`, COUNT(id)" + " FROM `dadguide`.`wave_data`" + " WHERE `dungeon_id` = {} AND `drop_monster_id` != 9900 AND `drop_monster_id` != 0" + " GROUP BY `floor_id`, `drop_monster_id`" + " ORDER BY `wave_data`.`floor_id` DESC;").format(dungeon_id) + cursor.execute(sql) + results2 = list(cursor.fetchall()) + + msg = 'floor_id,count\n' + for row in results1: + msg += ','.join(map(str, row.values())) + "\n" + msg += '\n\nfloor_id,monster_id,count\n' + for row in results2: + msg += ','.join(map(str, row.values())) + "\n" + + for page in pagify(msg): + await ctx.send(box(page)) + + @pipelineui.command() + @checks.is_owner() + async def cleardungeon(self, ctx, dungeon_id: int): + with self.get_cursor() as cursor: + sql = "DELETE FROM wave_data WHERE dungeon_id = {}".format(int(dungeon_id)) + cursor.execute(sql) + await ctx.tick() + + @pipelineui.group() + @is_padguidedb_admin() + async def pipeline(self, context): + """PadGuide pipeline utilities.""" + + @pipeline.command() + @is_padguidedb_admin() + async def fulletl(self, ctx, server='COMBINED'): + """Runs a job which downloads pad data, and updates the padguide database.""" + await self.runetl(ctx, '--server=' + server) + + @pipeline.command() + @is_padguidedb_admin() + async def savechanges(self, ctx): + """Runs a job which saves changes made by CRUD.""" + await self.runetl(ctx, '--skipdownload', '--processors=Monsters') + + async def runetl(self, ctx, *args): + if self.pipeline_lock.locked(): + await ctx.send(inline('Manual pipeline already running')) + return + + async with self.pipeline_lock: + await ctx.send(inline('Running pipeline: this could take a while')) + process = await asyncio.create_subprocess_exec( + 'bash', + self.settings.fullETLFile(), + *args, + + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + + if b"Pipeline completed successfully!" not in stderr: + logger.error("Pipeline Error:\n" + stderr.decode()) + await ctx.send(inline('Pipeline failed')) + else: + await ctx.send(inline('Pipeline finished')) + + @pipeline.command() + @is_padguidedb_admin() + async def processdungeon(self, ctx): + """Runs the dungeon processor script.""" + if self.dungeon_lock.locked(): + await ctx.send(inline('Dungeon processor already running')) + return + + async with self.dungeon_lock: + await ctx.send(inline('Running dungeon processor: this could take a while')) + process = await asyncio.create_subprocess_exec( + 'bash', + self.settings.dungeonProcessorFile(), + + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + for page in pagify(str(stderr)): + await ctx.send(box(page)) + await ctx.send(inline('Dungeon processing finished')) + + @pipeline.command() + @is_padguidedb_admin() + async def extractimages(self, ctx): + """Runs a job which downloads image updates, generates full images, and portraits.""" + if self.extract_images_lock.locked(): + await ctx.send(inline('Extract images already running')) + return + + async with self.extract_images_lock: + await ctx.send(inline('Running image extract pipeline: this could take a while')) + process = await asyncio.create_subprocess_exec( + 'bash', + self.settings.imageUpdateFile(), + + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + + if stderr: + logger.error("Image Extract Error:\n" + stderr.decode()) + await ctx.send(inline('Image extract failed')) + return + await ctx.send(inline('Image extract finished')) + + @pipelineui.command() + @checks.is_owner() + async def setdungeonscriptfile(self, ctx, *, dungeon_script_file): + """Set the dungeon script file.""" + self.settings.setDungeonScriptFile(dungeon_script_file) + await ctx.tick() + + @pipelineui.command() + @checks.is_owner() + async def setfulletlfile(self, ctx, *, full_etl_file): + """Set the full ETL file.""" + self.settings.setFullETLFile(full_etl_file) + await ctx.tick() + + @pipelineui.command() + @checks.is_owner() + async def setdungeonprocessorfile(self, ctx, *, full_etl_file): + """Set the full ETL file.""" + self.settings.setDungeonProcessorFile(full_etl_file) + await ctx.tick() + + @pipelineui.command() + @checks.is_owner() + async def setimageupdatefile(self, ctx, *, image_update_file): + """Set the image update file.""" + self.settings.setImageUpdateFile(image_update_file) + await ctx.tick() + + @pipelineui.command() + @checks.is_owner() + async def setpythonexecutable(self, ctx, *, python_executable): + """Set the python executable file.""" + self.settings.setPythonExecutable(python_executable) + await ctx.tick() + + @pipelineui.command() + @checks.is_owner() + async def setuserinfo(self, ctx, server: str, user_uuid: str, user_intid: str): + """Set the dungeon script.""" + self.settings.setUserInfo(server, user_uuid, user_intid) + await ctx.tick() + + +class PadGuideDbSettings(CogSettings): + def make_default_settings(self): + config = { + 'admins': [], + 'config_file': '', + 'dungeon_script_file': '', + 'full_etl_file': '', + 'dungeon_processor_file': '', + 'update_image_file': '', + 'python_executable': '/usr/bin/python3', + 'users': {}, + } + return config + + def admins(self): + return self.bot_settings['admins'] + + def checkAdmin(self, user_id): + admins = self.admins() + return user_id in admins + + def addAdmin(self, user_id): + admins = self.admins() + if user_id not in admins: + admins.append(user_id) + self.save_settings() + + def rmAdmin(self, user_id): + admins = self.admins() + if user_id in admins: + admins.remove(user_id) + self.save_settings() + + def configFile(self): + return self.bot_settings.get('config_file', '') + + def setConfigFile(self, config_file): + self.bot_settings['config_file'] = config_file + self.save_settings() + + def dungeonScriptFile(self): + return self.bot_settings.get('dungeon_script_file', '') + + def setDungeonScriptFile(self, dungeon_script_file): + self.bot_settings['dungeon_script_file'] = dungeon_script_file + self.save_settings() + + def fullETLFile(self): + return self.bot_settings.get('full_etl_file', '') + + def setFullETLFile(self, full_etl_file): + self.bot_settings['full_etl_file'] = full_etl_file + self.save_settings() + + def dungeonProcessorFile(self): + return self.bot_settings.get('dungeon_processor_file', '') + + def setDungeonProcessorFile(self, dungeon_processor_file): + self.bot_settings['dungeon_processor_file'] = dungeon_processor_file + self.save_settings() + + def imageUpdateFile(self): + return self.bot_settings.get('update_image_file', '') + + def setImageUpdateFile(self, update_image_file): + self.bot_settings['update_image_file'] = update_image_file + self.save_settings() + + def pythonExecutable(self): + return self.bot_settings.get('python_executable', '/usr/bin/python3') + + def setPythonExecutable(self, python_executable): + self.bot_settings['python_executable'] = python_executable + self.save_settings() + + def setUserInfo(self, server, user_uuid, user_intid): + self.bot_settings['users'][server.upper()] = [user_uuid, user_intid] + self.save_settings() + + def hasUserInfo(self, server): + return server.upper() in self.bot_settings['users'] + + def userUuidFor(self, server): + return self.bot_settings['users'][server.upper()][0] + + def userIntidFor(self, server): + return self.bot_settings['users'][server.upper()][1] diff --git a/playground/__init__.py b/playground/__init__.py deleted file mode 100644 index bb38f2fb..00000000 --- a/playground/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .playground import * - - -def setup(bot): - bot.add_cog(Playground(bot)) diff --git a/playground/playground.py b/playground/playground.py deleted file mode 100644 index 37fff9f4..00000000 --- a/playground/playground.py +++ /dev/null @@ -1,17 +0,0 @@ -import json -import re -from fnmatch import fnmatch - -from ply import lex -from redbot.core import checks -from redbot.core import commands -from redbot.core.utils.chat_formatting import box, pagify - -from rpadutils import rpadutils - -class Playground(commands.Cog): - """Just lil' fun things.""" - - def __init__(self, bot, *args, **kwargs): - super().__init__(*args, **kwargs) - self.bot = bot diff --git a/pricecheck/__init__.py b/pricecheck/__init__.py new file mode 100644 index 00000000..ef808853 --- /dev/null +++ b/pricecheck/__init__.py @@ -0,0 +1,7 @@ +from .pricecheck import PriceCheck + +__red_end_user_data_statement__ = "No personal data is stored." + + +async def setup(bot): + bot.add_cog(PriceCheck(bot)) if not __import__('asyncio').iscoroutinefunction(bot.add_cog) else await bot.add_cog(PriceCheck(bot)) diff --git a/pricecheck/info.json b/pricecheck/info.json new file mode 100644 index 00000000..ec6b785a --- /dev/null +++ b/pricecheck/info.json @@ -0,0 +1,29 @@ +{ + "author": [ + "Aradia Megido#2552" + ], + "description": "A cog that enables the display of pricing information for evolution material gems.", + "end_user_data_statement": "No personal data is stored.", + "install_msg": "Use [p]pcadmin to set up pricechecks.", + "short": "Price Check", + "min_bot_version": "3.4.0", + "max_bot_version": "0.0.0", + "min_python_version": [ + 3, + 8, + 1 + ], + "hidden": false, + "disabled": false, + "required_cogs": { + "globaladmin": "https://github.com/TsubakiBotPad/core-cogs", + "dbcog": "https://github.com/TsubakiBotPad/pad-cogs", + "padinfo": "https://github.com/TsubakiBotPad/pad-cogs" + }, + "requirements": [ + "tsutils" + ], + "tags": [ + "PAD" + ] +} diff --git a/pricecheck/pricecheck.py b/pricecheck/pricecheck.py new file mode 100644 index 00000000..2e6474e9 --- /dev/null +++ b/pricecheck/pricecheck.py @@ -0,0 +1,153 @@ +from io import BytesIO + +from redbot.core import Config, commands +from redbot.core.utils.chat_formatting import box, inline, pagify +from tsutils.cogs.globaladmin import auth_check + +DISCLAIMER = "**Disclaimer**: This is Lumon's data. Use at your own discretion." + +PC_TEXT = """{name} + - Stamina Cost: {stam_cost} + - Plus Point Value: {pp_val} ({points} 𝟤𝟫𝟩 Plus Points) + +{foot} +""" + + +def rint(x, p): + return str(round(x, p)).rstrip('0').rstrip('.') + + +class PriceCheck(commands.Cog): + """A pricechecking cog for Lumon""" + + def __init__(self, bot, *args, **kwargs): + super().__init__(*args, **kwargs) + self.bot = bot + self.config = Config.get_conf(self, identifier=9213337337) + self.config.register_global(pcs={}) + self.config.register_channel(dm=False) + GADMIN_COG = self.bot.get_cog("GlobalAdmin") + if GADMIN_COG: + GADMIN_COG.register_perm("pcadmin") + + async def red_get_data_for_user(self, *, user_id): + """Get a user's personal data.""" + data = "No data is stored for user with ID {}.\n".format(user_id) + return {"user_data.txt": BytesIO(data.encode())} + + async def red_delete_data_for_user(self, *, requester, user_id): + """Delete a user's personal data. + + No personal data is stored in this cog. + """ + return + + @commands.group(invoke_without_command=True) + async def pricecheck(self, ctx, *, query): + """Displays pricing data for a tradable non-collab gem.""" + dbcog = self.bot.get_cog("DBCog") + if dbcog is None: + await ctx.send(inline("Error: Cog not loaded. Please alert a bot owner.")) + return + if "gem" not in query.lower(): + query += " gem" + m = await dbcog.find_monster(query, ctx.author.id) + if not m: + await ctx.send("Monster not found.") + return + base_id = str(dbcog.database.graph.get_base_id(m)) + async with self.config.pcs() as pcs: + if base_id not in pcs: + if m.sell_mp < 100: + await ctx.send("{} does not have PC data.".format(m.name_en)) + else: + await ctx.send("{} is not tradable.".format(m.name_en)) + return + sc, foot = pcs[base_id] + pct = PC_TEXT.format(name=m.name_en, + stam_cost=rint(sc, 2), + pp_val=rint(sc * 83 / 50, 2), + points=rint(sc * 83 / 50 / 297, 2), + foot=foot) + if await self.config.channel(ctx.channel).dm(): + await ctx.send(DISCLAIMER) + for page in pagify(pct): + await ctx.author.send(box(page.replace("'", "ʼ"), lang='q')) + else: + await ctx.send(DISCLAIMER) + for page in pagify(pct): + await ctx.send(box(page.replace("'", "ʼ"), lang='q')) + + @commands.group() + @auth_check('pcadmin') + async def pcadmin(self, ctx): + """Creates custom commands for [p]pricecheck.""" + + @pcadmin.command(aliases=['add']) + async def set(self, ctx, stam_cost: float, *, query): + """Adds stamina cost data to a card.""" + dbcog = self.bot.get_cog("DBCog") + if dbcog is None: + await ctx.send(inline("Error: Cog not loaded. Please alert a bot owner.")) + return + if "gem" not in query.lower(): + query += " gem" + m = await dbcog.find_monster(query, ctx.author.id) + if not m: + await ctx.send("Monster not found.") + return + base_id = str(dbcog.database.graph.get_base_id(m)) + async with self.config.pcs() as pcs: + foot = "" + if base_id in pcs: + foot = pcs[base_id][1] + pcs[base_id] = (stam_cost, foot) + await ctx.send(box("Set {} ({}) to {}".format(m.name_en, base_id, stam_cost))) + + @pcadmin.command(aliases=['addfooter', 'addfoot', 'setfoot']) + async def setfooter(self, ctx, query, *, footer=""): + """Adds notes regarding the stamina cost of a card.""" + dbcog = self.bot.get_cog('DBCog') + if dbcog is None: + await ctx.send(inline("Error: Cog not loaded. Please alert a bot owner.")) + return + if "gem" not in query.lower(): + query += " gem" + m = await dbcog.find_monster(query, ctx.author.id) + if not m: + await ctx.send("Monster not found.") + return + base_id = str(dbcog.database.graph.get_base_id(m)) + async with self.config.pcs() as pcs: + sc = -1 + if base_id in pcs: + sc = pcs[base_id][0] + pcs[base_id] = (sc, footer.strip('`')) + await ctx.send(box("Set {} ({}) footer to '{}'".format(m.name_en, base_id, footer))) + + @pcadmin.command(aliases=['delete', 'del', 'rm']) + async def remove(self, ctx, *, query): + """Removes stamina cost data from a card.""" + dbcog = self.bot.get_cog('DBCog') + if dbcog is None: + await ctx.send(inline("Error: Cog not loaded. Please alert a bot owner.")) + return + if "gem" not in query.lower(): + query += " gem" + m = await dbcog.find_monster(query, ctx.author.id) + if not m: + await ctx.send("Monster not found.") + return + async with self.config.pcs() as pcs: + if str(dbcog.database.graph.get_base_id(m)) not in pcs: + await ctx.send("{} does not have PC data.".format(m.name_en)) + return + del pcs[str(dbcog.database.graph.get_base_id(m))] + await ctx.send(inline("Removed PC data from {}.".format(m.name_en))) + + @pcadmin.command() + async def setdmonly(self, ctx, value: bool = True): + """Tells a channel to send [p]pricecheck in dms.""" + await self.config.channel(ctx.channel).dm.set(value) + await ctx.tick() diff --git a/profile/__init__.py b/profile/__init__.py index 3132499e..047b785d 100644 --- a/profile/__init__.py +++ b/profile/__init__.py @@ -1,5 +1,7 @@ -from .profile import * +from .profile import Profile +__red_end_user_data_statement__ = "Profile data is stored persistantly." -def setup(bot): - bot.add_cog(Profile(bot)) + +async def setup(bot): + bot.add_cog(Profile(bot)) if not __import__('asyncio').iscoroutinefunction(bot.add_cog) else await bot.add_cog(Profile(bot)) diff --git a/profile/info.json b/profile/info.json new file mode 100644 index 00000000..5d2d577c --- /dev/null +++ b/profile/info.json @@ -0,0 +1,25 @@ +{ + "author": [ + "The Tsubaki Bot Team" + ], + "description": "Holds all of your Puzzle & Dragons accounts in one place", + "end_user_data_statement": "Profile data is stored persistantly.", + "install_msg": "Use [p]profile to get started", + "short": "P&D Phonebook", + "min_bot_version": "3.4.0", + "max_bot_version": "0.0.0", + "min_python_version": [ + 3, + 8, + 1 + ], + "hidden": false, + "disabled": false, + "required_cogs": {}, + "requirements": [ + "tsutils>=3.0.0" + ], + "tags": [ + "PAD" + ] +} diff --git a/profile/profile.py b/profile/profile.py index aa6acbfe..9eb40a69 100644 --- a/profile/profile.py +++ b/profile/profile.py @@ -1,72 +1,89 @@ -import re - import discord +import logging +import re +from io import BytesIO from redbot.core import commands -from redbot.core.bot import Red -from redbot.core.utils.chat_formatting import inline, box, pagify - -from rpadutils import CogSettings +from redbot.core.utils.chat_formatting import box, inline, pagify +from tsutils.cog_settings import CogSettings +from tsutils.formatting import normalize_server_name +logger = logging.getLogger('red.padbot-cogs.profile') -def normalizeServer(server): - server = server.upper().strip() - return 'NA' if server == 'US' else server +SUPPORTED_SERVERS = ["NA", "KR", "JP"] -SUPPORTED_SERVERS = ["NA", "KR", "JP", "EU"] - -def validateAndCleanId(id): - id = id.replace('-', '').replace(' ', '').replace(',', '').replace('.', '').strip() - if re.match(r'^\d{9}$', id): - return id +def validate_and_clean_id(pad_id): + pad_id = pad_id.replace('-', '').replace(' ', '').replace(',', '').replace('.', '').strip() + if re.match(r'^\d{9}$', pad_id): + return pad_id else: return None -def formatNameLine(server, pad_name, pad_id): - group = computeOldGroup(pad_id) - return "[{}]: '{}' : {} (Group {})".format(server, pad_name, formatId(pad_id), group) +def format_name_line(server, pad_name, pad_id): + group = compute_old_group(pad_id) + return "[{}]: '{}' : {} (Group {})".format(server, pad_name, format_id(pad_id), group) -def formatId(id): - return id[0:3] + "," + id[3:6] + "," + id[6:9] +def format_id(pad_id): + return pad_id[0:3] + "," + pad_id[3:6] + "," + pad_id[6:9] -def computeOldGroup(str_id): - old_id_digit = str_id[2] +def compute_old_group(str_id): + old_id_digit = str(str_id)[2] return chr(ord('A') + (int(old_id_digit) % 5)) class Profile(commands.Cog): - def __init__(self, bot: Red, *args, **kwargs): + """PAD Profile Cog""" + def __init__(self, bot, *args, **kwargs): super().__init__(*args, **kwargs) self.bot = bot self.settings = ProfileSettings("profile") - async def on_ready(self): - """ready""" - print("started profile") + async def red_get_data_for_user(self, *, user_id): + """Get a user's personal data.""" + udata = self.settings.getUserData(user_id) + + data = "Stored data for user with ID {}:\n".format(user_id) + if udata['servers']: + data += " - You have a profile on the following server(s): {}.\n".format(', '.join(udata['servers'])) + if udata['default_server']: + data += " - Your default server is {}.\n".format(udata['default_server']) + + if not any(udata.values()): + data = "No data is stored for user with ID {}.\n".format(user_id) + + return {"user_data.txt": BytesIO(data.encode())} - @commands.command(name="idme") - async def idMe(self, ctx, server=None): - """Prints out your profile to the current channel + async def red_delete_data_for_user(self, *, requester, user_id): + """Delete a user's personal data.""" + + if requester not in ("discord_deleted_user", "owner"): + self.settings.clearUserData(user_id) + else: + self.settings.clearUserDataFull(user_id) + + @commands.command() + async def idme(self, ctx, server=None): + """Displays your profile to the current channel If you do not provide a server, your default is used """ - profile_msg = await self.getIdMsg(ctx, ctx.author, server, False) + profile_msg = await self.get_id_msg(ctx, ctx.author, server, False) if profile_msg is None: return await ctx.send(profile_msg) - @commands.command(name="idto") - async def idTo(self, ctx, user: discord.Member, server=None): + @commands.command() + async def idto(self, ctx, user: discord.User, server=None): """Whispers your profile to specified user If you do not provide a server, your default is used """ - profile_msg = await self.getIdMsg(ctx, ctx.author, server) + profile_msg = await self.get_id_msg(ctx, ctx.author, server) if profile_msg is None: return @@ -76,38 +93,38 @@ async def idTo(self, ctx, user: discord.Member, server=None): await ctx.send(user, msg) await ctx.author.send(inline("Sent your profile to " + user.name)) - @commands.command(name="idfor") - async def idFor(self, ctx, user: discord.Member, server=None): - """Prints out the profile of the specified user + @commands.command() + async def idfor(self, ctx, user: discord.User, server=None): + """Displays the profile of the specified user If you do not provide a server, your default is used. """ - profile_msg = await self.getIdMsg(ctx, user, server) + profile_msg = await self.get_id_msg(ctx, user, server) if profile_msg is None: return await ctx.author.send(profile_msg) - async def getServer(self, ctx, user_id, server=None): + async def get_server(self, ctx, user_id, server=None): if server is None: server = self.settings.getDefaultServer(user_id) - server = normalizeServer(server) + server = normalize_server_name(server) if server not in SUPPORTED_SERVERS: await ctx.send(inline('Unsupported server: ' + server)) return return server - async def getIdMsg(self, ctx, user, server=None, for_other=True): - server = await self.getServer(ctx, user.id, server) + async def get_id_msg(self, ctx, user, server=None, for_other=True): + server = await self.get_server(ctx, user.id, server) if server is None: return - pad_id = self.settings.getId(user.id, server) + pad_id = str(self.settings.getId(user.id, server)) pad_name = self.settings.getName(user.id, server) profile_text = self.settings.getProfileText(user.id, server) line1 = "Info for " + user.name - line2 = formatNameLine(server, pad_name, pad_id) + line2 = format_name_line(server, pad_name, pad_id) line3 = profile_text msg = inline(line1) + "\n" + box(line2 + "\n" + line3) @@ -117,13 +134,13 @@ async def getIdMsg(self, ctx, user, server=None, for_other=True): async def profile(self, ctx): """Manage profile storage""" - @profile.command(name="server") - async def setServer(self, ctx, server): + @profile.command() + async def server(self, ctx, server): """Set your default server to one of [NA, EU, JP, KR] This server is used to default the idme command if you don't provide a server. """ - server = normalizeServer(server) + server = normalize_server_name(server) if server not in SUPPORTED_SERVERS: await ctx.send(inline('Unsupported server: ' + server)) return @@ -132,28 +149,27 @@ async def setServer(self, ctx, server): await ctx.send(inline('Set your default server to: ' + server)) @profile.command(name="id") - async def setId(self, ctx, server, *id): + async def _id(self, ctx, server, *, pad_id): """Sets your ID for a server ID must be 9 digits, can be space/comma/dash delimited. """ - server = await self.getServer(ctx, ctx.author.id, server) + server = await self.get_server(ctx, ctx.author.id, server) if server is None: return None - id = " ".join(id) - clean_id = validateAndCleanId(id) + clean_id = validate_and_clean_id(pad_id) if clean_id is None: await ctx.send(inline('Your ID looks invalid, expected a 9 digit code, got: {}'.format(id))) return self.settings.setId(ctx.author.id, server, clean_id) - await ctx.send(inline('Set your id for {} to: {}'.format(server, formatId(clean_id)))) + await ctx.send(inline('Set your id for {} to: {}'.format(server, format_id(clean_id)))) - @profile.command(name="name") - async def setName(self, ctx, server, *name): + @profile.command() + async def name(self, ctx, server, *name): """Sets your in game name for a server""" - server = await self.getServer(ctx, ctx, server) + server = await self.get_server(ctx, ctx, server) if server is None: return None @@ -161,13 +177,13 @@ async def setName(self, ctx, server, *name): self.settings.setName(ctx.author.id, server, name) await ctx.send(inline('Set your name for {} to: {}'.format(server, name))) - @profile.command(name="text") - async def setText(self, ctx, server, *text): + @profile.command() + async def text(self, ctx, server, *text): """Sets your profile text for the server. This info is used by the idme command and search. """ - server = await self.getServer(ctx, ctx.author.id, server) + server = await self.get_server(ctx, ctx.author.id, server) if server is None: return None @@ -180,7 +196,7 @@ async def setText(self, ctx, server, *text): self.settings.setProfileText(ctx.author.id, server, text) await ctx.send(inline('Set your profile for ' + server + ' to:\n' + text)) - @profile.command(name="clear") + @profile.command() async def clear(self, ctx, server=None): """Deletes your saved profile for a server @@ -191,17 +207,17 @@ async def clear(self, ctx, server=None): self.settings.clearProfile(user_id) await ctx.send(inline('Cleared your profile for all servers')) else: - server = normalizeServer(server) + server = normalize_server_name(server) self.settings.clearProfile(user_id, server) await ctx.send(inline('Cleared your profile for ' + server)) - @profile.command(name="search") + @profile.command() async def search(self, ctx, server, *search_text): """profile search Scans all profiles for the search text and PMs the results. """ - server = await self.getServer(ctx, ctx.author.id, server) + server = await self.get_server(ctx, ctx.author.id, server) if server is None: return None @@ -229,7 +245,7 @@ async def search(self, ctx, server, *search_text): msg = 'Displaying {} matches for server {}:\n'.format(len(matching_profiles), server) for p in matching_profiles: - pad_id = formatId(p['id']) + pad_id = format_id(p['id']) pad_name = p.get('name', 'unknown') profile_text = p['text'].replace('`', '') @@ -237,17 +253,16 @@ async def search(self, ctx, server, *search_text): line2 = profile_text msg = msg + line1 + "\n" + line2 + "\n\n" - await self.pageOutput(ctx, msg) + await self.page_output(ctx, msg) - async def pageOutput(self, ctx, msg): + async def page_output(self, ctx, msg): msg = msg.strip() msg = pagify(msg, ["\n"], shorten_by=20) for page in msg: try: await ctx.author.send(box(page)) except Exception as e: - print("page output failed " + str(e)) - print("tried to print: " + page) + logger.exception("page output failed " + str(e)) class ProfileSettings(CogSettings): @@ -280,12 +295,12 @@ def getProfile(self, user, server): profile[server] = {} return profile[server] - def setId(self, user, server, id): - self.getProfile(user, server)['id'] = id + def setId(self, user, server, pad_id): + self.getProfile(user, server)['id'] = int(pad_id) self.save_settings() def getId(self, user, server): - return self.getProfile(user, server).get('id', '000000000') + return self.getProfile(user, server).get('id', 000000000) def setName(self, user, server, name): self.getProfile(user, server)['name'] = name @@ -307,3 +322,26 @@ def clearProfile(self, user, server=None): else: self.getProfile(user, server).clear() self.save_settings() + + # GDPR Compliance Functions + def getUserData(self, user_id): + o = { + 'servers': [], + 'default_server': '', + } + + if user_id in self.bot_settings['default_servers']: + o['default_server'] = self.bot_settings['default_servers'][user_id] + if user_id in self.bot_settings['user_profiles']: + o['servers'] = list(self.bot_settings['user_profiles'][user_id]) + return o + + def clearUserData(self, user_id): + if user_id in self.bot_settings['default_servers']: + del self.bot_settings['default_servers'][user_id] + if user_id in self.bot_settings['user_profiles']: + del self.bot_settings['user_profiles'][user_id] + self.save_settings() + + def clearUserDataFull(self, user_id): + self.clearUserData(user_id) diff --git a/requirements.txt b/requirements.txt index c4de0d58..5e9d40d7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,18 +1,80 @@ -# LEGACY DEPS - WILL UNCOMMENT AS THINGS ARE CONFIRMED NECESSARY -setuptools -wheel -Red-DiscordBot[postgres] -backoff -prettytable -romkan -google-api -google-api-python-client -google-cloud -google-cloud-texttospeech -pymysql -ply -aioodbc # might require: sudo apt-get install unixodbc-dev python3.8-dev -opencv-python -Pillow -pytz -aiohttp +aiofiles==22.1.0 +aiohttp==3.7.4.post0 +aiohttp-json-rpc==0.13.3 +aiomysql==0.1.1 +aioodbc==0.3.3 +aiosqlite==0.17.0 +anyio==3.6.2 +appdirs==1.4.4 +apsw-wheels==3.36.0.post1 +async-timeout==3.0.1 +attrs==21.2.0 +azure-cognitiveservices-speech==1.24.0 +Babel==2.9.1 +backoff==1.10.0 +certifi==2022.9.24 +cffi==1.14.6 +chardet==4.0.0 +charset-normalizer==2.1.1 +click==8.0.1 +colorama==0.4.4 +commonmark==0.9.1 +contextlib2==21.6.0 +contourpy==1.0.5 +cycler==0.11.0 +discord-menu==0.16.15 +discord.py @ git+https://github.com/Rapptz/discord.py@3d914e08e0c7df370987592affb3655d2a12f7d1 +distro==1.6.0 +fastapi==0.85.1 +firebase_admin==6.0.1 +fonttools==4.37.4 +frozendict==2.3.4 +fuzzywuzzy==0.18.0 +h11==0.14.0 +idna==3.2 +Jinja2==3.1.2 +kiwisolver==1.4.4 +Markdown==3.3.4 +MarkupSafe==2.1.1 +multidict==5.1.0 +networkx==2.8.7 +numpy==1.23.4 +packaging==21.3 +Pillow==9.2.0 +ply==3.11 +prettytable==3.4.1 +protobuf==3.20.3 # This version is important +psutil==5.8.0 +pycparser==2.20 +pydantic==1.9.2 +pygit2==1.10.1 +Pygments==2.10.0 +PyMySQL==1.0.2 +PyNaCl==1.4.0 +pyodbc==4.0.30 # This version is important +pyparsing==3.0.9 +python-dateutil==2.8.2 +python-Levenshtein-wheels==0.13.2 +pytz==2021.1 +PyYAML==5.4.1 +red-commons==1.0.0 +Red-DiscordBot @ git+https://github.com/Cog-Creators/Red-DiscordBot@ae80e62a1359b4f9f4ef1fd4290bd8a383f4b74c +Red-Lavalink==0.11.0rc0 +regex==2022.9.13 +requests==2.28.1 +rich==10.9.0 +romkan @ git+https://github.com/TsubakiBotPad/python-romkan.git@31f0cbcf6f2dcde34127a74705d5945849f9e71f +schema==0.7.4 +six==1.16.0 +sniffio==1.3.0 +starlette==0.20.4 +tabulate==0.9.0 +# tensorflow # Oh god, tensorflow is huge. It's not *that* important. +tsutils==7.2.12 +tweepy==4.4.0 +typing-extensions==3.10.0.2 +urllib3==1.26.12 +uvicorn==0.19.0 +uvloop==0.16.0 +wcwidth==0.2.5 +yarl==1.6.3 diff --git a/rpadutils/__init__.py b/rpadutils/__init__.py deleted file mode 100644 index 6530747d..00000000 --- a/rpadutils/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .rpadutils import * - - -def setup(bot): - bot.add_cog(RpadUtils(bot)) diff --git a/rpadutils/rpadutils.py b/rpadutils/rpadutils.py deleted file mode 100644 index 3817f954..00000000 --- a/rpadutils/rpadutils.py +++ /dev/null @@ -1,736 +0,0 @@ -import asyncio -import concurrent.futures -import errno -import inspect -import json -import os -import re -import io -import signal -import time -import unicodedata -from functools import wraps -from typing import List, Optional - -import aiohttp -import backoff -import discord -import pytz -from discord.ext.commands import CommandNotFound -from redbot.core import commands, data_manager -from redbot.core.bot import Red -from redbot.core.utils.chat_formatting import box, pagify - -RPADCOG = None - -class RpadUtils(commands.Cog): - def __init__(self, bot: Red, *args, **kwargs): - super().__init__(*args, **kwargs) - self.bot = bot - - global RPADCOG - RPADCOG = self - - def user_allowed(self, message): - author = message.author - - if author.bot: - return False - return True - - -# TZ used for PAD NA -# NA_TZ_OBJ = pytz.timezone('America/Los_Angeles') -NA_TZ_OBJ = pytz.timezone('US/Pacific') - -# TZ used for PAD JP -JP_TZ_OBJ = pytz.timezone('Asia/Tokyo') - -# https://gist.github.com/ryanmcgrath/982242 -# UNICODE RANGE : DESCRIPTION -# 3000-303F : punctuation -# 3040-309F : hiragana -# 30A0-30FF : katakana -# FF00-FFEF : Full-width roman + half-width katakana -# 4E00-9FAF : Common and uncommon kanji -# -# Non-Japanese punctuation/formatting characters commonly used in Japanese text -# 2605-2606 : Stars -# 2190-2195 : Arrows -# u203B : Weird asterisk thing - -JP_REGEX_STR = r'[\u3000-\u303F]|[\u3040-\u309F]|[\u30A0-\u30FF]|[\uFF00-\uFFEF]|[\u4E00-\u9FAF]|[\u2605-\u2606]|[\u2190-\u2195]|\u203B' -JP_REGEX = re.compile(JP_REGEX_STR) - - -def containsJp(txt): - return JP_REGEX.search(txt) - - -class PermissionsError(CommandNotFound): - """ - Base exception for all others in this module - """ - - -class BadCommand(PermissionsError): - """ - Thrown when we can't decipher a command from string into a command object. - """ - pass - - -class RoleNotFound(PermissionsError): - """ - Thrown when we can't get a valid role from a list and given name - """ - pass - - -class SpaceNotation(BadCommand): - """ - Throw when, with some certainty, we can say that a command was space - notated, which would only occur when some idiot...fishy...tries to - surround a command in quotes. - """ - pass - - -def get_role(roles, role_string): - if role_string.lower() == "everyone": - role_string = "@everyone" - - role = discord.utils.find( - lambda r: r.name.lower() == role_string.lower(), roles) - - if role is None: - print("Could not find role named " + role_string) - raise commands.UserFeedbackCheckFailure("Could not find role named " + role_string) - - return role - - -def get_role_from_id(bot, guild, roleid): - try: - roles = guild.roles - except AttributeError: - guild = get_server_from_id(bot, guild) - try: - roles = guild.roles - except AttributeError: - raise RoleNotFound(guild, roleid) - - role = discord.utils.get(roles, id=roleid) - if role is None: - raise commands.UserFeedbackCheckFailure("Could not find role id {} in guild {}".format(roleid, guild.name)) - return role - - -def get_server_from_id(bot, serverid): - return discord.utils.get(bot.get_guild, id=serverid) - - -def normalizeServer(server): - server = server.upper() - return 'NA' if server == 'US' else server - - -def should_download(file_path, expiry_secs): - if not os.path.exists(file_path): - print("file does not exist, downloading " + file_path) - return True - - ftime = os.path.getmtime(file_path) - file_age = time.time() - ftime - #print("for " + file_path + " got " + str(ftime) + ", age " + - # str(file_age) + " against expiry of " + str(expiry_secs)) - - if file_age > expiry_secs: - print("file {} too old, download it".format(file_path)) - return True - else: - return False - - -def writeJsonFile(file_path, js_data): - with open(file_path, "w") as f: - json.dump(js_data, f, indent=4) - - -def readJsonFile(file_path): - with open(file_path, "r") as f: - return json.load(f) - - -def safe_read_json(file_path): - try: - return readJsonFile(file_path) - except Exception as ex: - print('failed to read', file_path, 'got exception', ex) - return {} - - -def ensure_json_exists(file_dir, file_name): - if not os.path.exists(file_dir): - print("Creating dir: ", file_dir) - os.makedirs(file_dir) - file_path = os.path.join(file_dir, file_name) - try: - readJsonFile(file_path) - except: - print('File missing or invalid json:', file_path) - writeJsonFile(file_path, {}) - - -@backoff.on_exception(backoff.expo, aiohttp.ClientError, max_time=60) -@backoff.on_exception(backoff.expo, aiohttp.ServerDisconnectedError, max_time=60) -async def async_cached_dadguide_request(file_path, file_url, expiry_secs): - if should_download(file_path, expiry_secs): - async with aiohttp.ClientSession() as session: - async with session.get(file_url) as resp: - assert resp.status == 200 - with open(file_path, 'wb') as f: - f.write(await resp.read()) - - -def writePlainFile(file_path, text_data): - with open(file_path, "w", encoding='utf-8') as f: - f.write(text_data) - - -def readPlainFile(file_path): - with open(file_path, "r", encoding='utf-8') as f: - return f.read() - - -async def makeAsyncPlainRequest(file_url): - async with aiohttp.ClientSession() as session: - async with session.get(file_url) as resp: - return await resp.text() - - -async def makeAsyncCachedPlainRequest(file_path, file_url, expiry_secs): - if should_download(file_path, expiry_secs): - resp = await makeAsyncPlainRequest(file_url) - writePlainFile(file_path, resp) - return readPlainFile(file_path) - - -async def boxPagifySay(say_fn, msg): - for page in pagify(msg, delims=["\n"]): - await say_fn(box(page)) - - -class Forbidden(): - pass - - -def default_check(payload): - return not payload.member.bot - - -class EmojiUpdater(object): - # a pass-through class that does nothing to the emoji dictionary - # or to the selected emoji - def __init__(self, emoji_to_embed, **kwargs): - self.emoji_dict = emoji_to_embed - self.selected_emoji = None - - def on_update(self, ctx, selected_emoji): - self.selected_emoji = selected_emoji - return True - - -class Menu(): - def __init__(self, bot): - self.bot = bot - - # Feel free to override this in your cog if you need to - self.emoji = { - 0: "0⃣", - 1: "1⃣", - 2: "2⃣", - 3: "3⃣", - 4: "4⃣", - 5: "5⃣", - 6: "6⃣", - 7: "7⃣", - 8: "8⃣", - 9: "9⃣", - 10: "🔟", - "next": "➡", - "back": "⬅", - "yes": "✅", - "no": "❌", - } - - # for use as an action - async def reaction_delete_message(self, bot, ctx, message): - await message.delete() - - async def custom_menu(self, ctx, emoji_to_message, selected_emoji, **kwargs): - """Creates and manages a new menu - Required arguments: - Type: - 1- number menu - 2- confirmation menu - 3- info menu (basically menu pagination) - 4- custom menu. If selected, choices must be a list of tuples. - Messages: - Strings or embeds to use for the menu. - Pass as a list for number menu - Optional arguments: - page (Defaults to 0): - The message in messages that will be displayed - timeout (Defaults to 15): - The number of seconds until the menu automatically expires - check (Defaults to default_check): - The same check that wait_for_reaction takes - is_open (Defaults to False): - Whether or not the menu can take input from any user - emoji (Defaults to self.emoji): - A dictionary containing emoji to use for the menu. - If you pass this, use the same naming scheme as self.emoji - message (Defaults to None): - The discord.Message to edit if present - """ - return await self._custom_menu(ctx, emoji_to_message, selected_emoji, **kwargs) - - async def show_menu(self, - ctx, - message, - new_message_content): - if message: - if isinstance(new_message_content, discord.Embed): - return await message.edit(embed=new_message_content) - else: - return await message.edit(content=new_message_content) - else: - if isinstance(new_message_content, discord.Embed): - return await ctx.send(embed=new_message_content) - else: - return await ctx.send(new_message_content) - - async def _custom_menu(self, ctx, emoji_to_message, selected_emoji, - allowed_action=True, **kwargs): - timeout = kwargs.get('timeout', 15) - message = kwargs.get('message', None) - - reactions_required = not message - new_message_content = emoji_to_message.emoji_dict[selected_emoji] - if allowed_action: - if not message: - message = await self.show_menu(ctx, message, new_message_content) - else: - await self.show_menu(ctx, message, new_message_content) - - if reactions_required: - for e in emoji_to_message.emoji_dict: - try: - await message.add_reaction(e) - except Exception as e: - # failed to add reaction, ignore - pass - - def check(payload): - return (kwargs.get('check', default_check)(payload) and - str(payload.emoji.name) in list(emoji_to_message.emoji_dict.keys()) and - payload.user_id == ctx.author.id and - payload.message_id == message.id) - - if not message: - raise ValueError(message, ctx) - return None, None - - try: - p = await self.bot.wait_for('raw_reaction_add', check=check, timeout=timeout) - except asyncio.TimeoutError: - p = None - - if p is None: - try: - await message.clear_reactions() - except Exception as e: - # This is expected when miru doesn't have manage messages - pass - return message, new_message_content - - react_emoji = p.emoji.name - react_action = emoji_to_message.emoji_dict[p.emoji.name] - - if inspect.iscoroutinefunction(react_action): - message = await react_action(self.bot, ctx, message) - elif inspect.isfunction(react_action): - message = react_action(ctx, message) - - # user function killed message, quit - if not message: - return None, None - - try: - await message.remove_reaction(react_emoji, p.member) - except: - # This is expected when miru doesn't have manage messages - pass - - # update the emoji mapping however we need to, or just pass through and do nothing - - allowed_action = emoji_to_message.on_update(ctx, react_emoji) - return await self._custom_menu( - ctx, emoji_to_message, emoji_to_message.selected_emoji, - timeout=timeout, - check=check, - message=message, - allowed_action=allowed_action) - - -def char_to_emoji(c): - c = c.lower() - if c >= '0' and c <= '9': - names = { - '0': '0⃣', - '1': '1⃣', - '2': '2⃣', - '3': '3⃣', - '4': '4⃣', - '5': '5⃣', - '6': '6⃣', - '7': '7⃣', - '8': '8⃣', - '9': '9⃣', - } - return names[c] - if c < 'a' or c > 'z': - return c - - base = ord('\N{REGIONAL INDICATOR SYMBOL LETTER A}') - adjustment = ord(c) - ord('a') - return chr(base + adjustment) - - -def fix_emojis_for_server(emoji_list, msg_text): - """Finds 'emoji-looking' substrings in msg_text and corrects them. - - If msg_text has something like '<:emoji_1_derp:13242342343>' and the server - contains an emoji named :emoji_2_derp: then it will be swapped out in - the message. - - This corrects an issue where a padglobal alias is created in one server - with an emoji, but it has a slightly different name in another server. - """ - # Find all emoji-looking things in the message - matches = re.findall(r'<:[0-9a-z_]+:\d{18}>', msg_text, re.IGNORECASE) - if not matches: - return msg_text - - # For each unique looking emoji thing - for m in set(matches): - # Create a regex for that emoji replacing the digit - m_re = re.sub(r'\d', r'&', m).rstrip("~") - for em in emoji_list: - # If the current emoji matches the regex, force a replacement - emoji_code = str(em) - if re.match(m_re, emoji_code, re.IGNORECASE): - msg_text = re.sub(m_re, emoji_code, msg_text, flags=re.IGNORECASE) - break - return msg_text - - -def replace_emoji_names_with_code(emoji_list, msg_text): - """Finds emoji-name substrings in msg_text and corrects them. - - If msg_text has something like ':emoji_1_derp:' and emoji_list contains - an emoji named 'emoji_1_derp' then the value will replaced with the full - emoji id. - - This allows a padglobal admin without nitro to create entries with emojis - from other servers. - """ - # First strip down actual emojis to just the names - msg_text = re.sub(r'<(:[0-9a-z_]+:)\d{18}>', r'\1', msg_text, flags=re.IGNORECASE) - - # Find all emoji-looking things in the message - matches = re.findall(r':[0-9a-z_]+:', msg_text, re.IGNORECASE) - if not matches: - return msg_text - - # For each unique looking emoji thing - for m in set(matches): - emoji_name = m.strip(':') - for e in emoji_list: - if e.name == emoji_name: - msg_text = msg_text.replace(m, str(e)) - return msg_text - - -def is_valid_image_url(url): - url = url.lower() - return url.startswith('http') and (url.endswith('.png') or url.endswith('.jpg')) - - -def extract_image_url(m): - if is_valid_image_url(m.content): - return m.content - if m.attachments and len(m.attachments) and is_valid_image_url(m.attachments[0].url): - return m.attachments[0].url - return None - - -def rmdiacritics(input): - ''' - Return the base character of char, by "removing" any - diacritics like accents or curls and strokes and the like. - ''' - output = '' - for c in input: - try: - desc = unicodedata.name(c) - cutoff = desc.find(' WITH ') - if cutoff != -1: - desc = desc[:cutoff] - output += unicodedata.lookup(desc) - except: - output += c - return output - - -def clean_global_mentions(content): - """Wipes out mentions to @everyone and @here.""" - return re.sub(r'(@)(\w)', '\\g<1>\u200b\\g<2>', content) - - -def intify(input): - if isinstance(input, dict): - return {intify(k): intify(v) for k, v in input.items()} - elif isinstance(input, (list, tuple)): - return [intify(x) for x in input] - elif isinstance(input, str) and input.isdigit(): - return int(input) - elif isinstance(input, str) and input.replace('.', '', 1).isdigit(): - return float(input) - else: - return input - - -class CogSettings(object): - SETTINGS_FILE_NAME = "legacy_settings.json" - - def __init__(self, cog_name): - self.folder = str(data_manager.cog_data_path(raw_name=cog_name)) - self.file_path = os.path.join(self.folder, CogSettings.SETTINGS_FILE_NAME) - - self.check_folder() - - self.default_settings = self.make_default_settings() - if not os.path.isfile(self.file_path): - self.bot_settings = self.default_settings - self.save_settings() - else: - current = intify(readJsonFile(self.file_path)) - updated = False - for key in self.default_settings.keys(): - if key not in current.keys(): - current[key] = self.default_settings[key] - updated = True - - self.bot_settings = current - if updated: - self.save_settings() - - def check_folder(self): - if not os.path.exists(self.folder): - print("Creating " + self.folder) - os.makedirs(self.folder) - - def save_settings(self): - writeJsonFile(self.file_path, self.bot_settings) - - def make_default_settings(self): - return {} - - -async def get_prefix(bot: Red, message: discord.Message, text: str = None) -> Optional[str]: - text = text or message.content or '' - for p in await get_prefixes(bot, message): - if text.startswith(p): - return p - return None - - -async def get_prefixes(bot: Red, message: discord.Message) -> List[str]: - prefixes = await bot.get_prefix(message) # This returns all server prefixes - if isinstance(prefixes, str): - prefixes = [prefixes] - - # In case some idiot sets a null prefix - if "" in prefixes: - prefixes.remove("") - - return prefixes - - -def strip_right_multiline(txt: str): - """Useful for prettytable output where there is a lot of right spaces,""" - return '\n'.join([x.strip() for x in txt.splitlines()]) - - -# This was overwritten by voltron. PDX opted to copy it +10,000 ids away -CROWS_1 = {x: x + 10000 for x in range(2601, 2635 + 1)} -# This isn't overwritten but PDX adjusted anyway -CROWS_2 = {x: x + 10000 for x in range(3460, 3481 + 1)} - -PDX_JP_ADJUSTMENTS = {} -PDX_JP_ADJUSTMENTS.update(CROWS_1) -PDX_JP_ADJUSTMENTS.update(CROWS_2) - - -def get_pdx_id(m): - pdx_id = m.monster_no_na - if int(m.monster_no) == m.monster_no_jp: - pdx_id = PDX_JP_ADJUSTMENTS.get(pdx_id, pdx_id) - return pdx_id - - -def get_pdx_id_dadguide(m): - pdx_id = m.monster_no_na - if int(m.monster_id) == m.monster_no_jp: - pdx_id = PDX_JP_ADJUSTMENTS.get(pdx_id, pdx_id) - return pdx_id - - -async def await_and_remove(bot, react_msg, listen_user, delete_msgs=None, emoji="❌", timeout=15): - try: - await react_msg.add_reaction(emoji) - except Exception as e: - # failed to add reaction, ignore - return - - def check(payload): - return str(payload.emoji.name) == emoji and \ - payload.user_id == listen_user.id and \ - payload.message_id == react_msg.id - - try: - p = await bot.wait_for('add_reaction', check=check, timeout=timeout) - except asyncio.TimeoutError: - # Expected after {timeout} seconds - p = None - - if p is None: - try: - await react_msg.remove_reaction(emoji, react_msg.guild.me) - except Exception as e: - # failed to remove reaction, ignore - return - else: - msgs = delete_msgs or [react_msg] - for m in msgs: - await m.delete_message() - - -loop_executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) - - -async def run_in_loop(bot, task, *args): - event_loop = asyncio.get_event_loop() - running_task = event_loop.run_in_executor(loop_executor, task, *args) - return await running_task - - -async def translate_jp_en(bot, jp_text): - translate_cog = bot.get_cog('Translate') - if not translate_cog: - return None - return await run_in_loop(bot, translate_cog.translate_jp_en, jp_text) - - -def validate_json(fp): - try: - json.load(open(fp)) - return True - except: - return False - - -def timeout_after(seconds=10, error_message=os.strerror(errno.ETIME)): - def decorator(func): - def _handle_timeout(signum, frame): - raise TimeoutError(error_message) - - def wrapper(*args, **kwargs): - signal.signal(signal.SIGALRM, _handle_timeout) - signal.setitimer(signal.ITIMER_REAL, seconds) - try: - result = func(*args, **kwargs) - finally: - signal.alarm(0) - return result - - return wraps(func)(wrapper) - - return decorator - - -async def confirm_message(ctx, text, yemoji = "✅", nemoji = "❌", timeout = 10): - msg = await ctx.send(text) - await msg.add_reaction(yemoji) - await msg.add_reaction(nemoji) - def check(reaction, user): - return (str(reaction.emoji) in [yemoji, nemoji] - and user.id == ctx.author.id - and reaction.message.id == msg.id) - - ret = False - try: - r, u = await ctx.bot.wait_for('reaction_add', check=check, timeout=timeout) - if r.emoji == yemoji: - ret = True - except asyncio.TimeoutError: - pass - - await msg.delete() - return ret - -class CtxIO(io.IOBase): - def __init__(self, ctx): - self.ctx = ctx - super(CtxIO, self).__init__() - - def read(self): - raise io.UnsupportedOperation("read") - - def write(self, data): - asyncio.ensure_future(self.ctx.send(data)) - -def corowrap(coro, loop): - def func(*args, **kwargs): - fut = asyncio.run_coroutine_threadsafe(coro, loop) - try: - fut.result() - except: - pass - return func - -def fawait(coro, loop): - fut = asyncio.run_coroutine_threadsafe(coro, loop) - try: - fut.result() - except: - pass - -async def doubleup(ctx, message): - lmessage = await ctx.history().__anext__() - fullmatch = re.escape(message) + r"(?: x(\d+))?" - match = re.match(fullmatch, lmessage.content) - if match and lmessage.author == ctx.bot.user: - n = match.group(1) or "1" - await lmessage.edit(content=message+" x"+str(int(n)+1)) - else: - await ctx.send(message) - -async def repeating_timer(seconds, condition=lambda:True, start_immediately=True): - if start_immediately: - yield - while True and condition(): - await asyncio.sleep(seconds) - yield diff --git a/scheduler/__init__.py b/scheduler/__init__.py deleted file mode 100644 index 791c6d29..00000000 --- a/scheduler/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .scheduler import * - -def setup(bot): - n = Scheduler(bot) - bot.loop.create_task(n.queue_manager()) - bot.add_cog(n) diff --git a/scheduler/scheduler.py b/scheduler/scheduler.py deleted file mode 100644 index aadf9552..00000000 --- a/scheduler/scheduler.py +++ /dev/null @@ -1,313 +0,0 @@ -import discord - -from redbot.core import commands, checks, Config -from redbot.core.utils.chat_formatting import * - -import logging -import os -import asyncio -import time -from random import randint -from math import ceil - -from rpadutils import CogSettings - -log = logging.getLogger("red.scheduler") -log.setLevel(logging.INFO) - - -class Event: - def __init__(self, data=None): - self.name = data.pop('name') - self.channel = data.pop('channel') - self.server = data.pop('server') - self.author = data.pop('author') - self.command = data.pop('command') - self.timedelta = data.pop('timedelta') - self.repeat = data.pop('repeat') - self.starttime = data.pop('starttime', None) - - def __lt__(self, other): - my_sig = "{}-{}-{}-{}".format(self.timedelta, self.name, - self.starttime, self.channel) - other_sig = "{}-{}-{}-{}".format(other.timedelta, other.name, - other.starttime, other.channel) - return hash(my_sig) < hash(other_sig) - - -class Scheduler(commands.Cog): - """Schedules commands to run every so often. - Times are formed as follows: 1s, 2m, 3h, 5d, 1w - """ - - def __init__(self, bot): - self.bot = bot - - self.config = Config.get_conf(self, identifier=537367732) - self.config.register_guild(events=[]) # We should use this, but aaaa - - self.settings = SchedulerSettings("scheduler") - self.events = self.settings.getevents() - self.queue = asyncio.PriorityQueue(loop=bot.loop) - self.queue_lock = asyncio.Lock() - self.to_kill = {} - self._load_events() - - def save_events(self): - self.settings.setevents(self.events) - - def _load_events(self): - # for entry in the self.events make an Event - for server in self.events: - for name, event in self.events[server].items(): - ret = {} - ret['server'] = server - ret.update(event) - e = Event(ret) - self.bot.loop.create_task(self._put_event(e)) - - async def _put_event(self, event, fut=None, offset=None): - if fut is None: - now = int(time.time()) - if event.repeat: - diff = now - event.starttime - fut = ((ceil(diff / event.timedelta) * event.timedelta) + - event.starttime) - else: - fut = now + event.timedelta - if offset: - fut += offset - await self.queue.put((fut, event)) - log.debug('Added "{}" to the scheduler queue at {}'.format(event.name, - fut)) - - async def _add_event(self, name, command, - dest_server: discord.Guild, - dest_channel: discord.TextChannel, - author: discord.User, - timedelta, repeat=False): - dest_server = dest_server.id - dest_channel = dest_channel.id - author = author.id - - if dest_server not in self.events: - self.events[dest_server] = {} - - event_dict = {'name': name, - 'channel': dest_channel, - 'author': author, - 'command': command, - 'timedelta': timedelta, - 'repeat': repeat} - - log.debug('event dict:\n\t{}'.format(event_dict)) - - now = int(time.time()) - event_dict['starttime'] = now - self.events[dest_server][name] = event_dict.copy() - - event_dict['server'] = dest_server - e = Event(event_dict.copy()) - await self._put_event(e) - - self.save_events() - - async def _remove_event(self, name, server): - async with self.queue_lock: - events = [] - while self.queue.qsize() != 0: - time, event = await self.queue.get() - if not (name == event.name and server.id == event.server): - events.append((time, event)) - - for event in events: - await self.queue.put(event) - self.queue_lock.release() - - @commands.group() - @commands.guild_only() - @checks.mod_or_permissions(manage_messages=True) - async def scheduler(self, ctx): - pass - - @scheduler.command() - async def add(self, ctx, time_interval, *, command): - """Add a command to run in [time_interval] seconds. - Times are formed as follows: 1s, 2m, 3h, 5d, 1w - """ - channel = ctx.channel - server = ctx.guild - author = ctx.author - name = command.lower() - try: - s = self._parse_time(time_interval) - log.debug('run command in {}s'.format(s)) - except: - await self.bot.send_cmd_help(ctx) - return - if s < 30: - await ctx.send('yeah I can\'t do that, your time' - ' interval is waaaay too short and I\'ll' - ' likely get rate limited. Try going above' - ' 30 seconds.') - return - log.info('add {} "{}" to {} on {} in {}s'.format( - name, command, channel.name, server.name, s)) - await self._add_event(name, command, server, channel, author, s) - await ctx.send('I will run "{}" in {}s'.format(command, s)) - - @scheduler.command() - async def repeat(self, ctx, name, time_interval, *, command): - """Add a command to run every [time_interval] seconds. - Times are formed as follows: 1s, 2m, 3h, 5d, 1w - """ - channel = ctx.channel - server = ctx.guild - author = ctx.author - name = name.lower() - try: - s = self._parse_time(time_interval) - log.debug('run command in {}s'.format(s)) - except: - await self.bot.send_cmd_help(ctx) - return - if s < 30: - await ctx.send('yeah I can\'t do that, your time' - ' interval is waaaay too short and I\'ll' - ' likely get rate limited. Try going above' - ' 30 seconds.') - return - log.info('add {} "{}" to {} on {} every {}s'.format( - name, command, channel.name, server.name, s)) - await self._add_event(name, command, server, channel, author, s, True) - await ctx.send('"{}" will run "{}" every {}s'.format(name, command, s)) - - @scheduler.command() - async def remove(self, ctx, name): - """Removes scheduled command from running. - """ - server = ctx.guild - name = name.lower() - if server.id not in self.events: - await ctx.send('No events are scheduled for this server.') - return - if name not in self.events[server.id]: - await ctx.send('That event does not exist on this server.') - return - - del self.events[server.id][name] - await self._remove_event(name, server) - self.save_events() - await ctx.send('"{}" has successfully been removed but' - ' it may run once more.'.format(name)) - - @scheduler.command() - async def list(self, ctx): - """Lists all repeated commands - """ - server = ctx.guild - if server.id not in self.events: - await ctx.send('No events scheduled for this server.') - return - elif len(self.events[server.id]) == 0: - await ctx.send('No events scheduled for this server.') - return - mess = "Names:\n\t" - mess += "\n\t".join(sorted(self.events[server.id].keys())) - await ctx.send(box(mess)) - - def _parse_time(self, time): - translate = {'s': 1, 'm': 60, 'h': 3600, 'd': 86400, 'w': 604800} - timespec = time[-1] - if timespec.lower() not in translate: - raise ValueError - timeint = int(time[:-1]) - return timeint * translate.get(timespec) - - def run_coro(self, event): - channel = self.bot.get_channel(event.channel) - try: - server = channel.guild - prefix = self.bot.settings.get_prefixes(server)[0] - except AttributeError: - log.debug("Channel no longer found, not running scheduled event.") - return - data = {} - data['timestamp'] = time.strftime("%Y-%m-%dT%H:%M:%S%z", time.gmtime()) - data['id'] = randint(10**(17), (10**18) - 1) - data['content'] = prefix + event.command - data['channel'] = self.bot.get_channel(event.channel) - data['author'] = {'id': event.author} - data['nonce'] = randint(-2**32, (2**32) - 1) - data['channel_id'] = event.channel - data['reactions'] = [] - fake_message = discord.Message(**data) - # coro = self.bot.process_commands(fake_message) - log.info("Running '{}' in {}".format(event.name, event.server)) - # self.bot.loop.create_task(coro) - self.bot.dispatch('message', fake_message) - - async def queue_manager(self): - while self == self.bot.get_cog('Scheduler'): - async with self.queue_lock: - if self.queue.qsize() != 0: - next_time, next_event = await self.queue.get() - diff = max(next_time-int(time.time()), 0) - if diff < 30: - log.debug('scheduling call of "{}" in {}s'.format( - next_event.name, diff)) - fut = self.bot.loop.call_later(diff, self.run_coro, - next_event) - self.to_kill[next_time] = fut - if next_event.repeat: - await self._put_event(next_event, next_time, - next_event.timedelta) - else: - del self.events[next_event.server][next_event.name] - self.save_events() - else: - log.debug('Will run {} "{}" in {}s'.format( - next_event.name, next_event.command, diff)) - await self._put_event(next_event, next_time) - - to_delete = [] - for start_time, old_command in self.to_kill.items(): - if time.time() > start_time + 30: - old_command.cancel() - to_delete.append(start_time) - for item in to_delete: - del self.to_kill[item] - - await asyncio.sleep(5) - log.debug('manager dying') - while self.queue.qsize() != 0: - await self.queue.get() - while len(self.to_kill) != 0: - curr = self.to_kill.pop() - curr.cancel() - - -def check_folder(): - if not os.path.exists('data/scheduler'): - os.mkdir('data/scheduler') - - -def check_files(): - f = 'data/scheduler/events.json' - if not os.path.exists(f): - fileIO(f, 'save', {}) - - -class SchedulerSettings(CogSettings): - def make_default_settings(self): - config = { - 'events': {}, - } - return config - - def getevents(self): - return self.bot_settings['events'] - - def setevents(self, events): - self.bot_settings['events'] = events - self.save_settings() diff --git a/schoolidol/__init__.py b/schoolidol/__init__.py deleted file mode 100644 index 0e01b9b3..00000000 --- a/schoolidol/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from .schoolidol import * - - -def setup(bot): - n = SchoolIdol(bot) - bot.add_cog(n) - bot.loop.create_task(n.reload_sif()) diff --git a/schoolidol/schoolidol.py b/schoolidol/schoolidol.py deleted file mode 100644 index 6a877e9a..00000000 --- a/schoolidol/schoolidol.py +++ /dev/null @@ -1,139 +0,0 @@ -import difflib -import json -from _collections import OrderedDict - -import aiohttp -import discord -from redbot.core import commands -from redbot.core.utils.chat_formatting import inline - -from rpadutils import Menu, EmojiUpdater, char_to_emoji - -FIRST_REQ = 'https://schoolido.lu/api/cards/?page_size=100' - - -class SchoolIdol(commands.Cog): - """SchoolIdol.""" - - def __init__(self, bot, *args, **kwargs): - super().__init__(*args, **kwargs) - self.bot = bot - self.card_data = [] - self.menu = Menu(bot) - self.regular_emoji = char_to_emoji('r') - self.idol_emoji = char_to_emoji('i') - - async def reload_sif(self): - await self.bot.wait_until_ready() - - next_req = FIRST_REQ - async with aiohttp.ClientSession() as session: - while next_req: - print(next_req) - async with session.get(next_req) as resp: - raw_resp = await resp.text() - js_resp = json.loads(raw_resp) - next_req = js_resp['next'] - self.card_data.extend(js_resp['results']) - print('done retrieving cards: {}'.format(len(self.card_data))) - - self.id_to_card = {c['id']: c for c in self.card_data} - name_to_card = {'{}'.format(c['idol']['name']).lower(): c for c in self.card_data} - firstname_to_card = {c['idol']['name'].lower().split(' ')[-1]: c for c in self.card_data} - collection_name_to_card = {'{} {}'.format( - c['translated_collection'], c['idol']['name']).lower(): c for c in self.card_data} - collection_firstname_to_card = {'{} {}'.format( - c['translated_collection'], c['idol']['name'].split(' ')[-1]).lower(): c for c in self.card_data} - self.names_to_card = { - **name_to_card, - **firstname_to_card, - **collection_name_to_card, - **collection_firstname_to_card, - } - - @commands.command() - async def sifid(self, ctx, *, query: str): - """SIF query.""" - query = query.lower().strip() - c = None - if query.isdigit(): - c = self.id_to_card.get(int(query), None) - else: - c = self.names_to_card.get(query, None) - if c is None: - matches = difflib.get_close_matches( - query, self.names_to_card.keys(), n=1, cutoff=.6) - if len(matches): - c = self.names_to_card[matches[0]] - - if c: - await self.do_menu(ctx, c) - else: - await ctx.send(inline('no matches')) - - async def do_menu(self, ctx, c): - emoji_to_embed = OrderedDict() - regular_embed = make_card_embed(c, IMAGE_FIELD) - if regular_embed: - emoji_to_embed[self.regular_emoji] = regular_embed - starting_menu_emoji = self.regular_emoji - - idol_embed = make_card_embed(c, IDOL_IMAGE_FIELD) - if idol_embed: - emoji_to_embed[self.idol_emoji] = idol_embed - starting_menu_emoji = self.idol_emoji - - if starting_menu_emoji is None: - await ctx.send(inline('no images found')) - return - - return await self._do_menu(ctx, starting_menu_emoji, emoji_to_embed) - - async def _do_menu(self, ctx, starting_menu_emoji, emoji_to_embed): - remove_emoji = self.menu.emoji['no'] - emoji_to_embed[remove_emoji] = self.menu.reaction_delete_message - - try: - result_msg, result_embed = await self.menu.custom_menu(ctx, - EmojiUpdater(emoji_to_embed), starting_menu_emoji, - timeout=20) - if result_msg and result_embed: - # Message is finished but not deleted, clear the footer - result_embed.set_footer(text=discord.Embed.Empty) - await self.bot.edit_message(result_msg, embed=result_embed) - except Exception as ex: - print('Menu failure', ex) - - -def make_card_embed(c, url_field): - cid = c['id'] - image_url = c[url_field] - if not image_url: - return None - - base_url = 'https://storage.googleapis.com/mirubot/sifimages/processed/{}_{}.png' - - embed = discord.Embed() - embed.title = toHeader(c) - embed.url = get_info_url(c) - embed.set_image(url=base_url.format(cid, url_field)) - embed.set_footer(text='Requester may click the reactions below to switch tabs') - return embed - - -IMAGE_FIELD = 'transparent_image' -IDOL_IMAGE_FIELD = 'transparent_idolized_image' - - -def toHeader(c): - cid = c['id'] - collection = c['translated_collection'] - name = c['idol']['name'] - if collection: - return 'No. {} {} {}'.format(cid, collection, name) - else: - return 'No. {} {}'.format(cid, name) - - -def get_info_url(c): - return 'https://schoolido.lu/cards/{}/'.format(c['id']) diff --git a/scorelistener/__init__.py b/scorelistener/__init__.py new file mode 100644 index 00000000..54aac2d2 --- /dev/null +++ b/scorelistener/__init__.py @@ -0,0 +1,7 @@ +from .scorelistener import ScoreListener + +__red_end_user_data_statement__ = "No personal data is stored." + + +async def setup(bot): + bot.add_cog(ScoreListener(bot)) if not __import__('asyncio').iscoroutinefunction(bot.add_cog) else await bot.add_cog(ScoreListener(bot)) diff --git a/scorelistener/info.json b/scorelistener/info.json new file mode 100644 index 00000000..ba7600b6 --- /dev/null +++ b/scorelistener/info.json @@ -0,0 +1,23 @@ +{ + "author": [ + "fluff#2368" + ], + "description": "Saves crown cutoff scores to ranking chart.", + "end_user_data_statement": "No personal data is stored.", + "install_msg": "", + "short": "Score Listener", + "min_bot_version": "3.4.0", + "max_bot_version": "0.0.0", + "min_python_version": [ + 3, + 8, + 1 + ], + "hidden": false, + "disabled": false, + "required_cogs": {}, + "requirements": [], + "tags": [ + "PAD" + ] +} diff --git a/scorelistener/scorelistener.py b/scorelistener/scorelistener.py new file mode 100644 index 00000000..0327029e --- /dev/null +++ b/scorelistener/scorelistener.py @@ -0,0 +1,121 @@ +import re +from io import BytesIO +from tsutils.cogs.globaladmin import auth_check +from redbot.core import Config, commands +from scorelistener.service_account import SERVICE_ACCOUNT +import firebase_admin +from firebase_admin import credentials, db + + +class ScoreListener(commands.Cog): + """Saves crown cutoff scores""" + + def __init__(self, bot, *args, **kwargs): + super().__init__(*args, **kwargs) + self.bot = bot + + self.config = Config.get_conf(self, identifier=10100779) + self.config.register_channel(enabled=False) + gadmin = self.bot.get_cog("GlobalAdmin") + if gadmin: + gadmin.register_perm("contentadmin") + + async def red_get_data_for_user(self, *, user_id): + """Get a user's personal data.""" + data = "No data is stored for user with ID {}.\n".format(user_id) + return {"user_data.txt": BytesIO(data.encode())} + + async def red_delete_data_for_user(self, *, requester, user_id): + """Delete a user's personal data. + + No personal data is stored in this cog. + """ + return + + @commands.Cog.listener('on_message') + async def on_message(self, message): + channel = message.channel + content = message.content + if not await self.config.channel(channel).enabled(): + return + if message.guild is None: # dms + return + if message.author == self.bot.user: # dont reply to self + return + if await self.is_command(message): # skip commands + return + if not re.match(r"^(\d){2,3}[ ,]?(\d){3}$", content): # invalid score + return + if not firebase_admin._apps: # firebase instance not started + return + + crown_ref = db.reference("/nacrowndata/") + max_date_ref = db.reference("/info/maxDate/") + + date_stamp = message.created_at.timestamp() * 1000 + if date_stamp >= max_date_ref.get(): # posting final cutoff + date_stamp = max_date_ref.get() + + crown_ref.push(value={ + "date": date_stamp, + "score": re.sub(r"[^0-9]", "", content) + }) + await message.add_reaction('\N{WHITE HEAVY CHECK MARK}') + + @commands.group() + async def scorelistener(self, ctx): + """Commands pertaining to score listener""" + + @scorelistener.command() + @auth_check('contentadmin') + async def enable(self, ctx): + """Enable score listener in this channel""" + await self.config.channel(ctx.channel).enabled.set(True) + await ctx.send("Enabled score listener in this channel.") + + @scorelistener.command() + @auth_check('contentadmin') + async def disable(self, ctx): + """Disable score listener in this channel""" + await self.config.channel(ctx.channel).enabled.set(False) + await ctx.send("Disabled score listener in this channel.") + + @scorelistener.command() + @auth_check('contentadmin') + async def deleteapp(self, ctx): + """Gracefully delete Firebase App instance""" + try: + firebase_admin.delete_app(firebase_admin.get_app()) + await ctx.tick() + except ValueError: + await ctx.send("App does not exist.") + + @scorelistener.command() + @auth_check('contentadmin') + async def startapp(self, ctx): + """Start Firebase App instance""" + api_keys = await self.bot.get_shared_api_tokens("firebase") + if not api_keys["private_key"] or not api_keys["private_key_id"]: + await ctx.send("The API keys are missing.") + return + + if firebase_admin._apps: + await ctx.send("There is already a Firebase App instance.") + return + SERVICE_ACCOUNT["private_key"] = api_keys["private_key"][:-1].replace(".", " ") + SERVICE_ACCOUNT["private_key_id"] = api_keys["private_key_id"] + cred = credentials.Certificate(SERVICE_ACCOUNT) + try: + firebase_admin.initialize_app(cred, { + 'databaseURL': "https://ranking-chart-default-rtdb.firebaseio.com" + }) + await ctx.tick() + except ValueError: + await ctx.send("The API keys are invalid.") + + async def is_command(self, msg): + prefixes = await self.bot.get_valid_prefixes() + for p in prefixes: + if msg.content.startswith(p): + return True + return False diff --git a/scorelistener/service_account.py b/scorelistener/service_account.py new file mode 100644 index 00000000..79ffde00 --- /dev/null +++ b/scorelistener/service_account.py @@ -0,0 +1,10 @@ +SERVICE_ACCOUNT = { + "type": "service_account", + "project_id": "ranking-chart", + "client_email": "firebase-adminsdk-16z6t@ranking-chart.iam.gserviceaccount.com", + "client_id": "109662611312775255755", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-16z6t%40ranking-chart.iam.gserviceaccount.com" +} diff --git a/selfroleoverride/__init__.py b/selfroleoverride/__init__.py deleted file mode 100644 index ef89ae6f..00000000 --- a/selfroleoverride/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .selfroleoverride import * - - -def setup(bot): - pdb = SelfRoleOverride(bot) - bot.add_cog(pdb) diff --git a/selfroleoverride/selfroleoverride.py b/selfroleoverride/selfroleoverride.py deleted file mode 100644 index aaddc258..00000000 --- a/selfroleoverride/selfroleoverride.py +++ /dev/null @@ -1,114 +0,0 @@ -import json -import re -import discord.utils -import asyncio - -from ply import lex -from redbot.core import checks, Config -from redbot.core import commands -from redbot.core.utils.chat_formatting import box, pagify - -class SelfRoleConverterOverride(commands.Converter): - async def convert(self, ctx: commands.Context, arg: str) -> discord.Role: - admin = ctx.command.cog.bot.get_cog("Admin") - if admin is None: - raise commands.BadArgument("The Admin cog is not loaded.") - - selfroles = await admin.config.guild(ctx.guild).selfroles() - role_converter = commands.RoleConverter() - - pool = [] - for rid in selfroles: - role = ctx.guild.get_role(rid) - if role is None: - continue - if role.name.lower() == arg.lower(): - pool.append(role) - if len(pool) == 0: - role = await role_converter.convert(ctx, arg) - - else: - if len(pool) > 1: - await ctx.send("This selfrole has more than one capitalization" - "possibilities. Please inform a moderator.") - role = pool[0] - - - - if role.id not in selfroles: - raise commands.BadArgument("The provided role is not a valid selfrole.") - return role - -class SelfRoleOverride(commands.Cog): - """Overrides of builtin commands""" - - def __init__(self, bot, *args, **kwargs): - super().__init__(*args, **kwargs) - self.bot = bot - - self.old_cmds = [] - for cmd in self.__cog_commands__: - old_cmd = bot.get_command(cmd.name) - if old_cmd: - bot.remove_command(old_cmd.name) - self.old_cmds.append(old_cmd) - - def cog_unload(self): - for cmd in self.old_cmds: - try: - self.bot.remove_command(cmd.name) - except: - pass - self.bot.add_command(cmd) - - - @commands.guild_only() - @commands.group() - async def selfrole(self, ctx: commands.Context): - """Apply selfroles.""" - pass - - @selfrole.command(name="add") - async def selfrole_add(self, ctx: commands.Context, *, selfrole: SelfRoleConverterOverride): - """ - Add a selfrole to yourself. - - Server admins must have configured the role as user settable. - """ - self = self.bot.get_cog("Admin") - if self is None: - await ctx.send("Admin cog not loaded.") - - await self._addrole(ctx, ctx.author, selfrole, check_user=False) - - @selfrole.command(name="remove") - async def selfrole_remove(self, ctx: commands.Context, *, selfrole: SelfRoleConverterOverride): - """ - Remove a selfrole from yourself. - - Server admins must have configured the role as user settable. - """ - self = self.bot.get_cog("Admin") - if self is None: - await ctx.send("Admin cog not loaded.") - - await self._removerole(ctx, ctx.author, selfrole, check_user=False) - - @selfrole.command(name="list") - async def selfrole_list(self, ctx: commands.Context): - """ - Lists all available selfroles. - """ - self = self.bot.get_cog("Admin") - if self is None: - await ctx.send("Admin cog not loaded.") - - selfroles = await self._valid_selfroles(ctx.guild) - fmt_selfroles = "\n".join(["+ " + r.name for r in selfroles]) - - if not fmt_selfroles: - await ctx.send("There are currently no selfroles.") - return - - msg = _("Available Selfroles:\n{selfroles}").format(selfroles=fmt_selfroles) - await ctx.send(box(msg, "diff")) diff --git a/seniority/__init__.py b/seniority/__init__.py deleted file mode 100644 index 3cdeedf5..00000000 --- a/seniority/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from .seniority import * - - -def setup(bot): - # TODO: Test this! - n = Seniority(bot) - bot.add_cog(n) - bot.loop.create_task(n.init()) diff --git a/seniority/seniority.py b/seniority/seniority.py deleted file mode 100644 index bc82024f..00000000 --- a/seniority/seniority.py +++ /dev/null @@ -1,974 +0,0 @@ -import os -import re -import sys -import timeit -from collections import deque -from datetime import datetime, timedelta - -import aioodbc -import discord -import prettytable -import pytz -from redbot.core import checks -from redbot.core import commands -from redbot.core.bot import Red -from redbot.core.commands import Context -from redbot.core.utils.chat_formatting import inline, pagify, box - -import rpadutils -from rpadutils import CogSettings, NA_TZ_OBJ - -CREATE_TABLE = ''' -CREATE TABLE IF NOT EXISTS seniority( - record_date STRING NOT NULL, - server_id STRING NOT NULL, - channel_id STRING NOT NULL, - user_id STRING NOT NULL, - points REAL DEFAULT 0, - PRIMARY KEY (record_date, server_id, channel_id, user_id)) -''' - -CREATE_INDEX_1 = ''' -CREATE INDEX IF NOT EXISTS idx_server_id_record_date_user_id -ON seniority(server_id, record_date, user_id) -''' - -# Why? -CREATE_INDEX_2 = ''' -CREATE INDEX IF NOT EXISTS idx_record_date_server_id_user_id -ON seniority(record_date, server_id, user_id) -''' - -# Why? record date should never come first -CREATE_INDEX_3 = ''' -CREATE INDEX IF NOT EXISTS idx_record_date_server_id_channel_id_user_id -ON seniority(record_date, server_id, channel_id, user_id) -''' - -CREATE_INDEX_4 = ''' -CREATE INDEX IF NOT EXISTS idx_server_id_user_id_record_date -ON seniority(server_id, user_id, record_date) -''' - -GET_USER_POINTS_QUERY = ''' -SELECT record_date, round(sum(points), 2) as points -FROM seniority INDEXED BY idx_server_id_user_id_record_date -WHERE server_id = ? - AND user_id = ? -GROUP BY 1 -ORDER BY 1 DESC -LIMIT ? -''' - -GET_LOOKBACK_POINTS_QUERY = ''' -SELECT user_id, sum(points) as points -FROM seniority INDEXED BY idx_server_id_user_id_record_date -WHERE server_id = ? - AND record_date >= ? -GROUP BY 1 -''' - -GET_DATE_POINTS_QUERY = ''' -SELECT channel_id, points -FROM seniority INDEXED BY idx_server_id_record_date_user_id -WHERE record_date = ? - AND server_id = ? - AND user_id = ? -''' - -GET_NEWMESSAGE_POINTS_QUERY = ''' -SELECT SUM(points) as points -FROM seniority INDEXED BY idx_record_date_server_id_channel_id_user_id -WHERE record_date = ? - AND server_id = ? - AND channel_id = ? - AND user_id = ? -''' - -GET_NEWMESSAGE_SERVER_POINTS_QUERY = ''' -SELECT SUM(points) as points -FROM seniority INDEXED BY idx_record_date_server_id_user_id -WHERE record_date = ? - AND server_id = ? - AND user_id = ? -''' - -REPLACE_POINTS_QUERY = ''' -REPLACE INTO seniority(record_date, server_id, channel_id, user_id, points) -VALUES(?, ?, ?, ?, ?) -''' - -DELETE_DAY_QUERY = ''' -DELETE FROM seniority -WHERE record_date = ? - AND server_id = ? -''' - - -class Seniority(commands.Cog): - """Automatically promote people based on activity.""" - - def __init__(self, bot: Red, *args, **kwargs): - super().__init__(*args, **kwargs) - self.bot = bot - self.settings = SenioritySettings("seniority") - self.db_path = self.settings.folder + '/log.db' - self.lock = True - self.pool = None - self.insert_timing = deque(maxlen=1000) - - def cog_unload(self): - print('Seniority: unloading') - self.lock = True - if self.pool: - self.pool.close() - self.pool = None - else: - print('unexpected error: pool was None') - print('Seniority: unloading complete') - - async def init(self): - print('Seniority: init') - if not self.lock: - print('Seniority: bailing on unlock') - return - - if os.name != 'nt' and sys.platform != 'win32': - dsn = 'Driver=SQLite3;Database=' + self.db_path - else: - dsn = 'Driver=SQLite3 ODBC Driver;Database=' + self.db_path - self.pool = await aioodbc.create_pool(dsn=dsn, autocommit=True) - async with self.pool.acquire() as conn: - async with conn.cursor() as cur: - await cur.execute(CREATE_TABLE) - await cur.execute(CREATE_INDEX_1) - await cur.execute(CREATE_INDEX_2) - await cur.execute(CREATE_INDEX_3) - await cur.execute(CREATE_INDEX_4) - self.lock = False - - print('Seniority: init complete') - - @commands.group() - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def seniority(self, ctx): - """Automatically promote people based on activity.""" - - @seniority.command() - @checks.is_owner() - async def rawquery(self, ctx, *, query: str): - await self.queryAndPrint(ctx, ctx.guild, query, []) - - @seniority.command() - @checks.is_owner() - async def inserttiming(self, ctx): - size = len(self.insert_timing) - avg_time = round(sum(self.insert_timing) / size, 4) - max_time = round(max(self.insert_timing), 4) - min_time = round(min(self.insert_timing), 4) - await ctx.send(inline('{} inserts, min={} max={} avg={}'.format(size, min_time, max_time, avg_time))) - - @seniority.command() - @checks.is_owner() - async def togglelock(self, ctx): - self.lock = not self.lock - await ctx.send(inline('Locked is now {}'.format(self.lock))) - - @seniority.command() - @commands.guild_only() - async def printconfig(self, ctx): - """Print the configuration for the server.""" - server = ctx.guild - server_id = server.id - msg = 'Config:' - announce_channel = self.get_announce_channel(server_id) - msg += '\n\tannounce_channel: {}'.format( - announce_channel.name if announce_channel else '') - msg += '\n\tauto_grant: {}'.format(self.settings.auto_grant(server_id)) - msg += '\n\tmessage_cap: {}'.format(self.settings.message_cap(server_id)) - msg += '\n\tserver_point_cap: {}'.format(self.settings.server_point_cap(server_id)) - msg += '\n\tgrant_lookback: {}'.format(self.settings.grant_lookback(server_id)) - msg += '\n\tremove_lookback: {}'.format(self.settings.remove_lookback(server_id)) - msg += '\n\n' - msg += 'Acceptability:' - msg += '\n\tignore_commands: {}'.format(self.settings.ignore_commands(server_id)) - msg += '\n\tignore_emoji: {}'.format(self.settings.ignore_emoji(server_id)) - msg += '\n\tignore_mentions: {}'.format(self.settings.ignore_mentions(server_id)) - msg += '\n\tignore_room_codes: {}'.format(self.settings.ignore_room_codes(server_id)) - msg += '\n\tmin_length: {}'.format(self.settings.min_length(server_id)) - msg += '\n\tmin_words: {}'.format(self.settings.min_words(server_id)) - msg += '\n\n' - msg += 'Ignored Users:' - for user_id in self.settings.blacklist(server_id): - member = server.get_member(int(user_id)) - msg += '\n\t{} ({})'.format(member.name if member else 'unknown', user_id) - msg += '\n\n' - msg += 'Channels and max ppd:' - for channel_id, config in self.settings.channels(server_id).items(): - channel = self.bot.get_channel(int(channel_id)) - msg += '\n\t{} : {}'.format(channel.name if channel else channel_id, config['max_ppd']) - msg += '\n\n' - msg += 'Roles:' - for role_id, config in self.settings.roles(server_id).items(): - try: - role = rpadutils.get_role_from_id(self.bot, server, role_id) - role_name = role.name - except: - role_name = role_id - msg += '\n\t{}'.format(role_name) - msg += '\n\t\tremove_amount : {}'.format(config['remove_amount']) - msg += '\n\t\twarn_amount : {}'.format(config['warn_amount']) - msg += '\n\t\tgrant_amount : {}'.format(config['grant_amount']) - - for page in pagify(msg): - await ctx.send(box(page)) - - def get_announce_channel(self, server_id): - announce_channel_id = self.settings.announce_channel(server_id) - return self.bot.get_channel(announce_channel_id) - - @seniority.group() - @commands.guild_only() - async def grant(self, ctx): - """Seniority grant and remove actions.""" - - @grant.command() - @commands.guild_only() - async def listbelow(self, ctx): - """List users below the remove amount.""" - lookback_days = self.settings.remove_lookback(ctx.guild.id) - await self.do_print_overages(ctx, ctx.guild, lookback_days, 'remove_amount', False) - - @grant.command() - @commands.guild_only() - async def listnear(self, ctx): - """List users above the warn amount.""" - lookback_days = self.settings.grant_lookback(ctx.guild.id) - await self.do_print_overages(ctx, ctx.guild, lookback_days, 'warn_amount', True) - - @grant.command() - @commands.guild_only() - async def listover(self, ctx): - """List users above the grant amount.""" - lookback_days = self.settings.grant_lookback(ctx.guild.id) - await self.do_print_overages(ctx, ctx.guild, lookback_days, 'grant_amount', True) - - @grant.command() - @commands.guild_only() - async def grantnow(self, ctx): - """List users above the grant amount.""" - guild = ctx.guild - lookback_days = self.settings.grant_lookback(guild.id) - for role_id, role, amount in self.roles_and_amounts(guild, 'grant_amount'): - if role is None or amount <= 0: - continue - - msg = 'Granting for role {} (point cutoff {})'.format(role.name, amount) - await ctx.send(inline(msg)) - - grant_users, ignored_users = await self.get_grant_ignore_users( - guild, role, amount, lookback_days, True) - grant_users = [guild.get_member(int(x[0])) for x in grant_users] - - cs = 5 - user_chunks = [grant_users[i:i + cs] for i in range(0, len(grant_users), cs)] - for chunk in user_chunks: - msg = 'Granting to users: ' + ','.join([m.name for m in chunk]) - await ctx.send(inline(msg)) - for member in chunk: - try: - await member.add_roles(role) - except Exception as ex: - raise commands.UserFeedbackCheckFailure(str(ex)) - - @grant.command() - @commands.guild_only() - async def removenow(self, ctx): - """List users below the remove amount.""" - server = ctx.guild - lookback_days = self.settings.remove_lookback(server.id) - for role_id, role, amount in self.roles_and_amounts(server, 'remove_amount'): - if role is None or amount <= 0: - continue - - msg = 'Removing for role {} (point cutoff {})'.format(role.name, amount) - await ctx.send(inline(msg)) - - grant_users, ignored_users = await self.get_grant_ignore_users( - server, role, amount, lookback_days, False) - grant_users = [server.get_member(int(x[0])) for x in grant_users] - - cs = 5 - user_chunks = [grant_users[i:i + cs] for i in range(0, len(grant_users), cs)] - for chunk in user_chunks: - msg = 'Removing from users: ' + ','.join([m.name for m in chunk]) - await ctx.send(inline(msg)) - for member in chunk: - try: - await member.remove_roles(role) - except Exception as ex: - raise commands.UserFeedbackCheckFailure(str(ex)) - - async def do_print_overages(self, - ctx: Context, - server: discord.Guild, - lookback_days: int, - check_name: str, - points_greater_than: bool): - await ctx.send(inline('Printing info for all roles')) - - for role_id, role, amount in self.roles_and_amounts(server, check_name): - if role is None: - await ctx.send(inline('Cannot find role with id ' + role_id)) - continue - - if amount <= 0: - await ctx.send(inline('Skipping role {} (disabled)'.format(role.name))) - continue - - grant_users, ignored_users = await self.get_grant_ignore_users( - server, role, amount, lookback_days, points_greater_than) - - def process_userlist(user_list): - r = '' - for userid_points in user_list: - user_id = userid_points[0] - member = server.get_member(int(user_id)) - member_name = member.name if member else user_id - points = round(userid_points[1], 2) - r += '\n\t{} ({}) : {}'.format(member_name, user_id, points) - return r - - msg = 'Modified users for role {} (point cutoff {})'.format(role.name, amount) - msg += process_userlist(grant_users) - msg += '\n\nIgnored users' - msg += process_userlist(ignored_users) - - for page in pagify(msg): - await ctx.send(box(page)) - - def roles_and_amounts(self, server: discord.Guild, check_name: str): - for role_id, role_config in self.settings.roles(server.id).items(): - amount = role_config[check_name] - try: - role = rpadutils.get_role_from_id(self.bot, server, role_id) - except: - role = None - yield role_id, role, amount - - async def get_grant_ignore_users(self, - server: discord.Guild, - role: discord.Role, - amount: int, - lookback_days: int, - points_greater_than: bool): - - if points_greater_than: - def point_check_fn(p): - return p >= amount - else: - def point_check_fn(p): - return p < amount - - users_and_points = await self.get_lookback_points(server, lookback_days) - grant_users, ignored_users = self.check_users_for_role( - users_and_points, server, point_check_fn, role, points_greater_than) - - return grant_users, ignored_users - - async def get_lookback_points(self, server: discord.Guild, lookback_days: int): - lookback_date = datetime.now(rpadutils.NA_TZ_OBJ) - timedelta(days=lookback_days) - lookback_date_str = lookback_date.date().isoformat() - - async with self.pool.acquire() as conn: - async with conn.cursor() as cur: - await cur.execute(GET_LOOKBACK_POINTS_QUERY, server.id, lookback_date_str) - rows = await cur.fetchall() - return [(int(x[0]), x[1]) for x in rows] - - def check_users_for_role(self, - users_and_points, - server: discord.Guild, - point_check_fn, - role: discord.Role, - adding_role: bool): - blacklisted_ids = self.settings.blacklist(server.id).keys() - - grant_users = [] - ignored_users = [] - - userid_to_points = {x[0]: x[1] for x in users_and_points} - for user in server.members: - points = userid_to_points.get(user.id, 0) - if role in user.roles and adding_role: - continue - if role not in user.roles and not adding_role: - continue - if not point_check_fn(points): - continue - - if user.id in blacklisted_ids: - ignored_users.append((user.id, points)) - else: - grant_users.append((user.id, points)) - - return grant_users, ignored_users - - @seniority.command() - @commands.guild_only() - async def userhistory(self, ctx, user: discord.User, limit=30): - """Print the points per day for a user.""" - limit = min(limit, 90) - server = ctx.guild - args = [server.id, user.id, limit] - await self.queryAndPrint(ctx, server, GET_USER_POINTS_QUERY, args, reverse=True, total=True) - - @seniority.command() - @commands.guild_only() - async def usercurrent(self, ctx, user: discord.User): - """Print the current day's points for a user.""" - server = ctx.guild - args = [now_date(), server.id, user.id] - await self.queryAndPrint(ctx, server, GET_DATE_POINTS_QUERY, args) - - @seniority.command() - @commands.guild_only() - async def blacklist(self, ctx, user: discord.User, reason: str): - """Ensure a user never gets a role auto granted.""" - server = ctx.guild - by = ctx.author - self.settings.add_blacklist(server.id, user.id, by.id, reason) - await ctx.send(inline('Set blacklist')) - - @seniority.command() - @commands.guild_only() - async def unblacklist(self, ctx, user: discord.User): - """Remove the blacklist for a user.""" - server = ctx.guild - if self.settings.remove_blacklist(server.id, user.id): - await ctx.send(inline('Removed blacklist')) - else: - await ctx.send(inline('That user was not blacklisted')) - - @seniority.command() - @commands.guild_only() - async def checktext(self, ctx, text: str): - """Check if text is considered significant by the current config. - """ - is_good, cleaned_text, reason = await self.check_acceptable(ctx.message, text) - if is_good: - await ctx.send(box('Message accepted, cleaned text:\n{}'.format(cleaned_text))) - else: - await ctx.send(box('Message rejected ({}), cleaned text:\n{}'.format(reason, cleaned_text))) - - @seniority.group() - @commands.guild_only() - async def config(self, ctx): - """Toggle Seniority configuration settings.""" - pass - - @config.command() - @commands.guild_only() - async def announcechannel(self, ctx, channel: discord.TextChannel): - """Set the announcement channel.""" - self.settings.set_announce_channel(ctx.guild.id, channel.id) - await ctx.send(inline('Done.')) - - @config.command() - @commands.guild_only() - async def messagecap(self, ctx, cap_amount: int): - """Set the number of messages required to reach maximum points for a channel.""" - self.settings.set_message_cap(ctx.guild.id, cap_amount) - await ctx.send(inline('Done.')) - - @config.command() - @commands.guild_only() - async def serverpointcap(self, ctx, cap_amount: int): - """Set the maximum number of points per day a user can receive in the server.""" - self.settings.set_server_point_cap(ctx.guild.id, cap_amount) - await ctx.send(inline('Done.')) - - @config.command() - @commands.guild_only() - async def toggleautogrant(self, ctx): - """Enable or disable the automatic granting of roles.""" - server_id = ctx.guild.id - new_setting = not self.settings.auto_grant(server_id) - self.settings.set_auto_grant(server_id, new_setting) - await ctx.send(inline('Auto grant set to {}.'.format(new_setting))) - - @config.command() - @commands.guild_only() - async def grantlookback(self, ctx, days: int): - """Number of days to look back when computing points for granting a role.""" - server_id = ctx.guild.id - self.settings.set_grant_lookback(server_id, days) - await ctx.send(inline('Grant lookback set to {}.'.format(days))) - - @config.command() - @commands.guild_only() - async def removelookback(self, ctx, days: int): - """Number of days to look back when computing points for removing a role.""" - server_id = ctx.guild.id - self.settings.set_remove_lookback(server_id, days) - await ctx.send(inline('Remove lookback set to {}.'.format(days))) - - @config.command() - @commands.guild_only() - async def channel(self, ctx, channel: discord.TextChannel, max_points_per_day: int): - """Maximum points per day a user can earn in a channel (0 disables).""" - server_id = ctx.guild.id - self.settings.set_channel(server_id, channel.id, max_points_per_day) - if max_points_per_day: - await ctx.send(inline('Max points set to {}.'.format(max_points_per_day))) - else: - await ctx.send(inline('Channel disabled')) - - @config.command() - @commands.guild_only() - async def role(self, ctx, role_name: str, remove_amount: int, warn_amount: int, grant_amount: int): - """Set the configuration for a role. - - role_name: Role name to configure - remove_amount: Automatic role removal threshold. Set to 0 to disable. - warn_amount: Print a warning in the announcement channel when exceeded. Set to 0 to disable. - grant_amount: Automatic role grant threshold. Set to 0 to disable. - - remove_amount must be less than grant_amount. - Set all three to 0 to delete the entry. - """ - server = ctx.guild - role = rpadutils.get_role(server.roles, role_name) - self.settings.set_role(server.id, role.id, remove_amount, warn_amount, grant_amount) - - if remove_amount == 0 and grant_amount == 0: - await ctx.send(inline('Role configuration deleted')) - return - - msg = 'Done.' - if remove_amount <= 0: - msg += '\nThis role will not be automatically removed.' - else: - msg += '\nThis role will be removed when points in remove window drop below {}.'.format( - remove_amount) - if grant_amount <= 0: - msg += '\nThis role will not be automatically granted.' - else: - if warn_amount <= 0: - msg += '\nNo early warning will be given for this role.' - else: - msg += '\nI will warn when a user exceeds {} points in the grant window.'.format( - warn_amount) - msg += '\nThis role will be granted when points in grant window exceed {}.'.format( - grant_amount) - - await ctx.send(box(msg)) - - @seniority.group() - @commands.guild_only() - async def acceptable(self, ctx): - """Toggle Seniority text acceptability settings.""" - - @acceptable.command() - @commands.guild_only() - async def togglecommands(self, ctx): - """Toggle rejection of bot commands.""" - server_id = ctx.guild.id - new_setting = not self.settings.ignore_commands(server_id) - self.settings.set_ignore_commands(server_id, new_setting) - await ctx.send(inline('ignore_commands set to {}.'.format(new_setting))) - - @acceptable.command() - @commands.guild_only() - async def toggleemoji(self, ctx): - """Toggle deletion of emoji from text.""" - server_id = ctx.guild.id - new_setting = not self.settings.ignore_emoji(server_id) - self.settings.set_ignore_emoji(server_id, new_setting) - await ctx.send(inline('ignore_emoji set to {}.'.format(new_setting))) - - @acceptable.command() - @commands.guild_only() - async def togglementions(self, ctx): - """Toggle deletion of mentions from text.""" - server_id = ctx.guild.id - new_setting = not self.settings.ignore_mentions(server_id) - self.settings.set_ignore_mentions(server_id, new_setting) - await ctx.send(inline('ignore_mentions set to {}.'.format(new_setting))) - - @acceptable.command() - @commands.guild_only() - async def toggleroomcodes(self, ctx): - """Toggle rejection of text containing room codes.""" - server_id = ctx.guild.id - new_setting = not self.settings.ignore_room_codes(server_id) - self.settings.set_ignore_room_codes(server_id, new_setting) - await ctx.send(inline('ignore_room_codes set to {}.'.format(new_setting))) - - @acceptable.command() - @commands.guild_only() - async def minlength(self, ctx, length: int): - """Set the minimum length of text.""" - server_id = ctx.guild.id - self.settings.set_min_length(server_id, length) - await ctx.send(inline('Min text length set to {}.'.format(length))) - - @acceptable.command() - @commands.guild_only() - async def minwords(self, ctx, words: int): - """Set the minimum number of words in text.""" - server_id = ctx.guild.id - self.settings.set_min_words(server_id, words) - await ctx.send(inline('Min word count set to {}.'.format(words))) - - async def check_acceptable(self, message: discord.Message, text: str): - server = message.guild - server_id = server.id - if self.settings.ignore_commands(server_id): - if await rpadutils.get_prefix(self.bot, message): - return False, text, 'Ignored command' - - if self.settings.ignore_room_codes(server_id): - if re.match(r'.*\d{4}\s?\d{4}.*', text): - return False, text, 'Ignored room code' - - if self.settings.ignore_emoji(server_id): - text = re.sub(r'<:[0-9a-z_]+:\d{18}>', '', text, re.IGNORECASE) - - if self.settings.ignore_mentions(server_id): - text = re.sub(r'<@\d{18}>', '', text, re.IGNORECASE) - - if len(text) < self.settings.min_length(server_id): - return False, text, 'Min length' - - if len(text.split()) < self.settings.min_words(server_id): - return False, text, 'Min words' - - return True, text, 'Passed!' - - @commands.Cog.listener("on_message") - async def on_message(self, message: discord.Message): - if message.guild is None: - return - now_date_str = now_date() - await self.process_message(message, now_date_str) - - async def process_message(self, message: discord.Message, now_date_str: str): - if self.lock: - return - - guild = message.guild - channel = message.channel - user = message.author - - if guild is None or message.author.id == self.bot.user.id: - return - - channel_config = self.settings.channels(guild.id).get(channel.id, None) - if not channel_config: - return - - acceptable, _, _ = await self.check_acceptable(message, message.content) - if not acceptable: - return - - max_points = channel_config['max_ppd'] - current_points = await self.get_current_channel_points(now_date_str, guild, channel, user) - current_points = current_points or 0 - - if current_points >= max_points: - return - - server_point_cap = self.settings.server_point_cap(guild.id) - current_server_points = await self.get_current_server_points(now_date_str, guild, user) - current_server_points = current_server_points or 0 - - if current_server_points >= server_point_cap: - return - - message_cap = self.settings.message_cap(guild.id) - incremental_points = max_points / message_cap - new_points = current_points + incremental_points - new_points = min(new_points, max_points) - - before_time = timeit.default_timer() - await self.save_current_points(now_date_str, guild, channel, user, new_points) - execution_time = timeit.default_timer() - before_time - self.insert_timing.append(execution_time) - - return incremental_points - - async def get_current_channel_points(self, now_date_str: str, server: discord.Guild, channel: discord.TextChannel, - user: discord.User): - async with self.pool.acquire() as conn: - async with conn.cursor() as cur: - await cur.execute(GET_NEWMESSAGE_POINTS_QUERY, now_date_str, server.id, channel.id, user.id) - results = await cur.fetchone() - return results.points if results else 0 - - async def get_current_server_points(self, now_date_str: str, server: discord.Guild, user: discord.User): - async with self.pool.acquire() as conn: - async with conn.cursor() as cur: - await cur.execute(GET_NEWMESSAGE_SERVER_POINTS_QUERY, now_date_str, server.id, user.id) - results = await cur.fetchone() - return results.points if results else 0 - - async def save_current_points(self, now_date_str: str, server: discord.Guild, channel: discord.TextChannel, - user: discord.User, new_points: int): - async with self.pool.acquire() as conn: - async with conn.cursor() as cur: - await cur.execute(REPLACE_POINTS_QUERY, now_date_str, server.id, channel.id, user.id, new_points) - - async def queryAndPrint(self, ctx, server, query, values, max_rows=100, reverse=False, total=False): - before_time = timeit.default_timer() - async with self.pool.acquire() as conn: - async with conn.cursor() as cur: - await cur.execute(query, *values) - rows = await cur.fetchall() - columns = [x[0] for x in cur.description] - execution_time = timeit.default_timer() - before_time - - if reverse: - rows.reverse() - - tbl = prettytable.PrettyTable(columns) - tbl.hrules = prettytable.HEADER - tbl.vrules = prettytable.NONE - tbl.align = 'l' - - grand_total = 0 - - for idx, row in enumerate(rows): - if idx > max_rows: - break - - table_row = list() - for cidx, col in enumerate(columns): - raw_value = row[cidx] - value = str(raw_value) - if col == 'timestamp': - # Assign a UTC timezone to the datetime - raw_value = raw_value.replace(tzinfo=pytz.utc) - # Change the UTC timezone to PT - raw_value = NA_TZ_OBJ.normalize(raw_value) - value = raw_value.strftime("%F %X") - elif col == 'channel_id': - channel = server.get_channel(int(value)) if server else None - value = channel.name if channel else value - elif col == 'user_id': - member = server.get_member(int(value)) if server else None - value = member.name if member else value - elif col == 'server_id': - server_obj = self.bot.get_guild(int(value)) - value = server_obj.name if server_obj else value - elif cidx + 1 == len(columns): - grand_total += force_number(value) - table_row.append(value) - - tbl.add_row(table_row) - - result_text = "{} results fetched in {}s\n{}".format( - len(rows), round(execution_time, 2), tbl.get_string()) - - if total: - result_text += '\n\nTotal: {}'.format(grand_total) - - for p in pagify(result_text): - await ctx.send(box(p)) - - -def ensure_map(item, key, default_value): - if key not in item: - item[key] = default_value - return item[key] - - -def now_date(): - return datetime.now(rpadutils.NA_TZ_OBJ).date().isoformat() - - -class SenioritySettings(CogSettings): - def make_default_settings(self): - config = { - 'servers': {} - } - return config - - def servers(self): - return self.bot_settings['servers'] - - def server(self, server_id: str): - servers = self.servers() - return ensure_map(servers, server_id, {}) - - def config(self, server_id: str): - server = self.server(server_id) - config = ensure_map(server, 'config', {}) - ensure_map(config, 'announce_channel', '') - ensure_map(config, 'auto_grant', False) - ensure_map(config, 'message_cap', 20) - ensure_map(config, 'server_point_cap', 5) - ensure_map(config, 'grant_lookback', 90) - ensure_map(config, 'remove_lookback', 90) - return config - - def announce_channel(self, server_id: str): - return self.config(server_id)['announce_channel'] - - def auto_grant(self, server_id: str): - return self.config(server_id)['auto_grant'] - - def message_cap(self, server_id: str): - return self.config(server_id)['message_cap'] - - def server_point_cap(self, server_id: str): - return self.config(server_id)['server_point_cap'] - - def grant_lookback(self, server_id: str): - return self.config(server_id)['grant_lookback'] - - def remove_lookback(self, server_id: str): - return self.config(server_id)['remove_lookback'] - - def set_announce_channel(self, server_id: str, channel_id): - self.config(server_id)['announce_channel'] = channel_id - self.save_settings() - - def set_auto_grant(self, server_id: str, auto_grant: bool): - self.config(server_id)['auto_grant'] = auto_grant - self.save_settings() - - def set_message_cap(self, server_id, message_cap: int): - self.config(server_id)['message_cap'] = message_cap - self.save_settings() - - def set_server_point_cap(self, server_id, server_point_cap: int): - self.config(server_id)['server_point_cap'] = server_point_cap - self.save_settings() - - def set_grant_lookback(self, server_id: str, lookback: int): - self.config(server_id)['grant_lookback'] = lookback - self.save_settings() - - def set_remove_lookback(self, server_id: str, lookback: int): - self.config(server_id)['remove_lookback'] = lookback - self.save_settings() - - def roles(self, server_id): - server = self.server(server_id) - return ensure_map(server, 'roles', {}) - - def set_role(self, server_id: str, role_id: str, remove_amount: int, warn_amount: int, grant_amount: int): - roles = self.roles(server_id) - if remove_amount == 0 and grant_amount == 0: - roles.pop(role_id, None) - elif remove_amount >= grant_amount: - raise commands.UserFeedbackCheckFailure('remove_amount must be less than grant_amount') - elif warn_amount >= grant_amount: - raise commands.UserFeedbackCheckFailure('warn_amount must be less than grant_amount') - elif remove_amount < 0 or warn_amount < 0 or grant_amount < 0: - raise commands.UserFeedbackCheckFailure('role values must be >= 0') - else: - roles[role_id] = { - 'role_id': role_id, - 'remove_amount': remove_amount, - 'warn_amount': warn_amount, - 'grant_amount': grant_amount, - } - self.save_settings() - - def utterances(self, server_id: str): - server = self.server(server_id) - utterances = ensure_map(server, 'utterances', {}) - ensure_map(utterances, 'ignore_commands', True) - ensure_map(utterances, 'ignore_emoji', True) - ensure_map(utterances, 'ignore_mentions', True) - ensure_map(utterances, 'ignore_room_codes', True) - ensure_map(utterances, 'min_length', 30) - ensure_map(utterances, 'min_words', 5) - return utterances - - def ignore_commands(self, server_id: str): - return self.utterances(server_id)['ignore_commands'] - - def ignore_emoji(self, server_id: str): - return self.utterances(server_id)['ignore_emoji'] - - def ignore_mentions(self, server_id: str): - return self.utterances(server_id)['ignore_mentions'] - - def ignore_room_codes(self, server_id: str): - return self.utterances(server_id)['ignore_room_codes'] - - def min_length(self, server_id: str): - return self.utterances(server_id)['min_length'] - - def min_words(self, server_id: str): - return self.utterances(server_id)['min_words'] - - def set_ignore_commands(self, server_id: str, ignore: bool): - self.utterances(server_id)['ignore_commands'] = ignore - self.save_settings() - - def set_ignore_emoji(self, server_id: str, ignore: bool): - self.utterances(server_id)['ignore_emoji'] = ignore - self.save_settings() - - def set_ignore_mentions(self, server_id: str, ignore: bool): - self.utterances(server_id)['ignore_mentions'] = ignore - self.save_settings() - - def set_ignore_room_codes(self, server_id: str, ignore: bool): - self.utterances(server_id)['ignore_room_codes'] = ignore - self.save_settings() - - def set_min_length(self, server_id: str, min_length: int): - self.utterances(server_id)['min_length'] = min_length - self.save_settings() - - def set_min_words(self, server_id: str, min_words: int): - self.utterances(server_id)['min_words'] = min_words - self.save_settings() - - def blacklist(self, server_id: str): - server = self.server(server_id) - return ensure_map(server, 'blacklist', {}) - - def add_blacklist(self, server_id: str, user_id: str, by_id: str, reason: str): - blacklist = self.blacklist(server_id) - blacklist[user_id] = { - 'user_id': user_id, - 'by_id': by_id, - 'ignore_date': now_date(), - 'reason': reason, - } - self.save_settings() - - def remove_blacklist(self, server_id: str, user_id: str): - blacklist = self.blacklist(server_id) - result = blacklist.pop(user_id, None) - self.save_settings() - return result - - def channels(self, server_id: str): - server = self.server(server_id) - return ensure_map(server, 'channels', {}) - - def set_channel(self, server_id: str, channel_id: str, max_ppd: int): - channels = self.channels(server_id) - if max_ppd == 0: - channels.pop(channel_id, None) - else: - channels[channel_id] = { - 'channel_id': channel_id, - 'max_ppd': max_ppd, - } - self.save_settings() - - -def force_number(s): - try: - return float(s) - except ValueError: - return 0 diff --git a/speech/__init__.py b/speech/__init__.py deleted file mode 100644 index c051d94e..00000000 --- a/speech/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .speech import Speech - - -def setup(bot): - # TODO: Test! I don't have an API! - bot.add_cog(Speech(bot)) diff --git a/speech/speech.py b/speech/speech.py deleted file mode 100644 index 387e0a66..00000000 --- a/speech/speech.py +++ /dev/null @@ -1,142 +0,0 @@ -import asyncio - -import discord -from google.cloud import texttospeech -from google.oauth2 import service_account -from redbot.core import checks -from redbot.core import commands -from redbot.core.bot import Red -from redbot.core.utils.chat_formatting import inline - -from rpadutils import CogSettings, corowrap - -try: - if not discord.opus.is_loaded(): - discord.opus.load_opus('libopus.so.0') -except: # Missing opus - print('Failed to load opus') - opus = None -else: - opus = True - -SPOOL_PATH = "data/speech/spool.mp3" - - -class Speech(commands.Cog): - """Speech utilities.""" - - def __init__(self, bot: Red, *args, **kwargs): - super().__init__(*args, **kwargs) - self.bot = bot - self.settings = SpeechSettings("speech") - - self.service = None - self.try_setup_api() - self.busy = False - - def try_setup_api(self): - api_key_file = self.settings.get_key_file() - if api_key_file: - try: - credentials = service_account.Credentials.from_service_account_file(api_key_file) - self.service = texttospeech.TextToSpeechClient(credentials=credentials) - except: - print('speech setup failed') - - @commands.group() - @checks.is_owner() - async def speech(self, ctx): - """Speech utilities.""" - - @commands.command() - @checks.is_owner() - async def vcsay(self, ctx, *, text): - """Speak into the current user's voice channel.""" - if not self.service: - await ctx.send(inline('Set up an API key file first!')) - return - - voice = ctx.author.voice - if not voice: - await ctx.send(inline('You must be in a voice channel to use this command')) - return - - channel = voice.voice_channel - - if len(text) > 300: - await ctx.send(inline('Command is too long')) - return - - await self.speak(ctx, channel, text) - - async def speak(self, ctx, channel, text: str): - if self.busy: - await ctx.send(inline('Sorry, saying something else right now')) - return False - else: - self.busy = True - - try: - voice = texttospeech.types.VoiceSelectionParams( - language_code='en-US', name='en-US-Wavenet-F') - - audio_config = texttospeech.types.AudioConfig( - audio_encoding=texttospeech.enums.AudioEncoding.MP3) - - synthesis_input = texttospeech.types.SynthesisInput(text=text) - response = self.service.synthesize_speech(synthesis_input, voice, audio_config) - - with open(SPOOL_PATH, 'wb') as out: - out.write(response.audio_content) - - await self.play_path(channel, SPOOL_PATH) - return True - finally: - self.busy = False - return False - - async def play_path(self, channel, audio_path: str): - existing_vc = channel.guild.voice_client - if existing_vc: - await existing_vc.disconnect(force=True) - - voice_client = None - try: - voice_client = await channel.connect() - await asyncio.sleep(.5) - b_options = '-guess_layout_max 0 -v 16' - a_options = '' - - audio_source = discord.FFmpegPCMAudio(audio_path, options=a_options, before_options=b_options) - voice_client.play(audio_source, after = corowrap(voice_client.disconnect(), self.bot.loop)) - return True - except Exception as e: - print(e) - if voice_client: - try: - await voice_client.disconnect() - except: - pass - return False - - @speech.command() - @checks.is_owner() - async def setkeyfile(self, ctx, api_key_file): - """Sets the google api key file.""" - self.settings.set_key_file(api_key_file) - await ctx.send("done, make sure the key file is in the data/speech directory") - - -class SpeechSettings(CogSettings): - def make_default_settings(self): - config = { - 'google_api_key_file': '' - } - return config - - def get_key_file(self): - return self.bot_settings.get('google_api_key_file') - - def set_key_file(self, api_key_file): - self.bot_settings['google_api_key_file'] = api_key_file - self.save_settings() diff --git a/sqlactivitylog/__init__.py b/sqlactivitylog/__init__.py deleted file mode 100644 index 2abd6ca8..00000000 --- a/sqlactivitylog/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .sqlactivitylog import * - - -def setup(bot): - n = SqlActivityLogger(bot) - bot.add_cog(n) diff --git a/sqlactivitylog/sqlactivitylog.py b/sqlactivitylog/sqlactivitylog.py deleted file mode 100644 index 8f99f078..00000000 --- a/sqlactivitylog/sqlactivitylog.py +++ /dev/null @@ -1,417 +0,0 @@ -import os -import sqlite3 as lite -import textwrap -import timeit -from collections import deque -from datetime import datetime, timedelta - -import discord -import prettytable -import pytz -from redbot.core import checks, commands, data_manager -from redbot.core.bot import Red -from redbot.core.utils.chat_formatting import inline, box, pagify - -import rpadutils - - -def mod_help(self, ctx, help_type): - hs = getattr(self, help_type) - return self.format_text_for_context(ctx, hs).format(ctx) if hs else hs - - -commands.Command.format_help_for_context = lambda s, c: mod_help(s, c, "help") -commands.Command.format_shortdoc_for_context = lambda s, c: mod_help(s, c, "short_doc") - - -def _data_file(file_name: str) -> str: - return os.path.join(str(data_manager.cog_data_path(raw_name='sqlactivitylog')), file_name) - - -TIMESTAMP_FORMAT = '%Y-%m-%d %X' # YYYY-MM-DD HH:MM:SS -DB_FILE = _data_file("log.db") - -ALL_COLUMNS = [ - ('timestamp', 'Time (PT)'), - ('server_id', 'Server'), - ('channel_id', 'Channel'), - ('user_id', 'User'), - ('msg_type', 'Type'), - ('clean_content', 'Message'), -] - -CREATE_TABLE = ''' -CREATE TABLE IF NOT EXISTS messages( - rowid INTEGER PRIMARY KEY ASC AUTOINCREMENT, - timestamp TIMESTAMP NOT NULL, - server_id STRING NOT NULL, - channel_id STRING NOT NULL, - user_id STRING NOT NULL, - msg_type STRING NOT NULL, - content STRING NOT NULL, - clean_content STRING NOT NULL) -''' - -CREATE_INDEX_1 = ''' -CREATE INDEX IF NOT EXISTS idx_messages_server_id_channel_id_user_id_timestamp -ON messages(server_id, channel_id, user_id, timestamp) -''' - -CREATE_INDEX_2 = ''' -CREATE INDEX IF NOT EXISTS idx_messages_server_id_user_id_timestamp -ON messages(server_id, user_id, timestamp) -''' - -CREATE_INDEX_3 = ''' -CREATE INDEX IF NOT EXISTS idx_messages_server_id_clean_content -ON messages(server_id, clean_content) -''' - -CREATE_INDEX_4 = ''' -CREATE INDEX IF NOT EXISTS idx_messages_server_id_timestamp -ON messages(server_id, timestamp) -''' - -CREATE_INDEX_5 = ''' -CREATE INDEX IF NOT EXISTS idx_messages_server_id_channel_id_timestamp -ON messages(server_id, channel_id, timestamp) -''' - -MAX_LOGS = 500 - -USER_QUERY = ''' -SELECT * FROM ( - SELECT timestamp, channel_id, msg_type, clean_content - FROM messages INDEXED BY idx_messages_server_id_user_id_timestamp - WHERE server_id = :server_id - AND user_id = :user_id - ORDER BY timestamp DESC - LIMIT :row_count -) -ORDER BY timestamp ASC -''' - -CHANNEL_QUERY = ''' -SELECT * FROM ( - SELECT timestamp, user_id, msg_type, clean_content - FROM messages INDEXED BY idx_messages_server_id_channel_id_timestamp - WHERE server_id = :server_id - AND channel_id = :channel_id - AND user_id <> :bot_id - ORDER BY timestamp DESC - LIMIT :row_count -) -ORDER BY timestamp ASC -''' - -USER_CHANNEL_QUERY = ''' -SELECT * FROM ( - SELECT timestamp, msg_type, clean_content - FROM messages INDEXED BY idx_messages_server_id_channel_id_user_id_timestamp - WHERE server_id = :server_id - AND user_id = :user_id - AND channel_id = :channel_id - ORDER BY timestamp DESC - LIMIT :row_count -) -ORDER BY timestamp ASC -''' - -CONTENT_QUERY = ''' -SELECT * FROM ( - SELECT timestamp, channel_id, user_id, msg_type, clean_content - FROM messages INDEXED BY idx_messages_server_id_clean_content - WHERE server_id = :server_id - AND lower(clean_content) LIKE lower(:content_query) - AND user_id <> :bot_id - ORDER BY timestamp DESC - LIMIT :row_count -) -ORDER BY timestamp ASC -''' - -DELETE_BEFORE_QUERY = ''' -DELETE -FROM messages -WHERE timestamp < :before -''' - - -class SqlActivityLogger(commands.Cog): - """Log activity seen by bot""" - - def __init__(self, bot: Red, *args, **kwargs): - super().__init__(*args, **kwargs) - self.bot = bot - self.lock = False - self.con = lite.connect(DB_FILE, detect_types=lite.PARSE_DECLTYPES) - self.con.row_factory = lite.Row - self.con.execute(CREATE_TABLE) - self.con.execute(CREATE_INDEX_1) - self.con.execute(CREATE_INDEX_2) - self.con.execute(CREATE_INDEX_3) - self.con.execute(CREATE_INDEX_4) - self.con.execute(CREATE_INDEX_5) - self.insert_timing = deque(maxlen=1000) - self.purge() - - def cog_unload(self): - self.lock = True - self.con.close() - - @commands.command() - @checks.is_owner() - async def rawquery(self, ctx, *, query: str): - await self.queryAndPrint(ctx, ctx.guild, query, {}, {}) - - @commands.command() - @checks.is_owner() - async def inserttiming(self, ctx): - size = len(self.insert_timing) - avg_time = round(sum(self.insert_timing) / size, 4) - max_time = round(max(self.insert_timing), 4) - min_time = round(min(self.insert_timing), 4) - await ctx.send(inline('{} inserts, min={} max={} avg={}'.format(size, min_time, max_time, avg_time))) - - @commands.command() - @checks.is_owner() - async def togglelock(self, ctx): - """Prevents the bot from inserting into the db. - - This is useful if you need to use sqlite3 to access the database for some reason. - Will keep the bot from locking up trying to insert records. - """ - self.lock = not self.lock - await ctx.send(inline('Locked is now {}'.format(self.lock))) - - @commands.group() - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def exlog(self, context): - """Extra log querying tools. - - Uses the bot's local SQL message storage to retrieve deleted/edited messages. - """ - - @exlog.command() - async def user(self, ctx, user: discord.User, count=10): - """exlog user "{0.author.name}" 100 - - List of messages for a user across all channels. - Count is optional, with a low default and a maximum value. - """ - count = min(count, MAX_LOGS) - server = ctx.guild - values = { - 'server_id': server.id, - 'row_count': count, - 'user_id': user.id, - } - column_data = [ - ('timestamp', 'Time (PT)'), - ('channel_id', 'Channel'), - ('msg_type', 'Type'), - ('clean_content', 'Message'), - ] - - await self.queryAndPrint(ctx, server, USER_QUERY, values, column_data) - - @exlog.command() - async def channel(self, ctx, channel: discord.TextChannel, count=10): - """exlog channel #general_chat 100 - - List of messages in a given channel. - Count is optional, with a low default and a maximum value. - The bot is excluded from results. - """ - count = min(count, MAX_LOGS) - server = channel.guild - values = { - 'server_id': server.id, - 'bot_id': self.bot.user.id, - 'row_count': count, - 'channel_id': channel.id, - } - column_data = [ - ('timestamp', 'Time (PT)'), - ('user_id', 'User'), - ('msg_type', 'Type'), - ('clean_content', 'Message'), - ] - - await self.queryAndPrint(ctx, server, CHANNEL_QUERY, values, column_data) - - @exlog.command() - async def userchannel(self, ctx, user: discord.User, channel: discord.TextChannel, count=10): - """exlog userchannel "{0.author.name}" #general_chat 100 - - List of messages from a user in a given channel. - Count is optional, with a low default and a maximum value. - """ - count = min(count, MAX_LOGS) - server = channel.guild - values = { - 'server_id': server.id, - 'row_count': count, - 'channel_id': channel.id, - 'user_id': user.id, - } - column_data = [ - ('timestamp', 'Time (PT)'), - ('msg_type', 'Type'), - ('clean_content', 'Message'), - ] - - await self.queryAndPrint(ctx, server, USER_CHANNEL_QUERY, values, column_data) - - @exlog.command() - async def query(self, ctx, query, count=10): - """exlog query "4 whale" 100 - - Case-insensitive search of messages from every user/channel. - Put the query in quotes if it is more than one word. - Count is optional, with a low default and a maximum value. - The bot is excluded from results. - """ - if query[0] in ('%', '_'): - await ctx.send('`You cannot start this query with a wildcard`') - return - - count = min(count, MAX_LOGS) - server = ctx.guild - values = { - 'server_id': server.id, - 'bot_id': self.bot.user.id, - 'row_count': count, - 'content_query': query, - } - column_data = [ - ('timestamp', 'Time (PT)'), - ('channel_id', 'Channel'), - ('user_id', 'User'), - ('msg_type', 'Type'), - ('clean_content', 'Message'), - ] - - await self.queryAndPrint(ctx, server, CONTENT_QUERY, values, column_data) - - async def queryAndPrint(self, ctx, server, query, values, column_data, max_rows=MAX_LOGS * 2, verbose=True): - before_time = timeit.default_timer() - cursor = self.con.execute(query, values) - rows = cursor.fetchall() - execution_time = timeit.default_timer() - before_time - - if len(column_data) == 0: - column_data = ALL_COLUMNS - - results_columns = [d[0] for d in cursor.description] - column_data = [r for r in column_data if r[0] in results_columns] - for missing_col in [col for col in results_columns if col not in [c[0] for c in column_data]]: - column_data.append((missing_col, missing_col)) - - column_names = [c[0] for c in column_data] - column_headers = [c[1] for c in column_data] - - tbl = prettytable.PrettyTable(column_headers) - tbl.hrules = prettytable.HEADER - tbl.vrules = prettytable.NONE - tbl.align = 'l' - - for idx, row in enumerate(rows): - if idx > max_rows: - break - - table_row = list() - for col in column_names: - if col not in row.keys(): - table_row.append('') - continue - raw_value = row[col] - value = str(raw_value) - if col == 'timestamp': - # Assign a UTC timezone to the datetime - raw_value = raw_value.replace(tzinfo=pytz.utc) - # Change the UTC timezone to PT - raw_value = rpadutils.NA_TZ_OBJ.normalize(raw_value) - value = raw_value.strftime("%F %X") - if col == 'channel_id': - channel = server.get_channel(value) if server else None - value = channel.name if channel else value - if col == 'user_id': - member = server.get_member(value) if server else None - value = member.name if member else value - if col == 'server_id': - server_obj = self.bot.get_guild(value) - value = server_obj.name if server_obj else value - if col == 'clean_content': - value = value.replace('```', '~~~') - value = value.replace('`', '\\`') - value = '\n'.join(textwrap.wrap(value, 60)) - table_row.append(value) - - tbl.add_row(table_row) - - if verbose: - result_text = "{} results fetched in {}s\n{}".format( - len(rows), round(execution_time, 2), tbl.get_string()) - for p in pagify(result_text): - await ctx.send(box(p)) - - @commands.Cog.listener("on_message_edit") - async def on_message_edit(self, before, after): - self.log('EDIT', before, after.edited_at) - - @commands.Cog.listener("on_message_delete") - async def on_message_delete(self, message): - self.log('DELETE', message, datetime.utcnow()) - - def log(self, msg_type, message, timestamp): - if self.lock: - return - - if message.author.id == self.bot.user.id: - return - - stmt = ''' - INSERT INTO messages(timestamp, server_id, channel_id, user_id, msg_type, content, clean_content) - VALUES(:timestamp, :server_id, :channel_id, :user_id, :msg_type, :content, :clean_content) - ''' - timestamp = timestamp or datetime.utcnow() - server_id = message.guild.id if message.guild else -1 - channel_id = message.channel.id if message.channel else -1 - - msg_content = message.content - msg_clean_content = message.clean_content - if message.attachments: - extra_txt = '\nattachments: ' + str(message.attachments) - msg_content = (msg_content + extra_txt).strip() - msg_clean_content = (msg_clean_content + extra_txt).strip() - - if message.embeds: - extra_txt = '\nembeds: ' + str(message.embeds) - msg_content = (msg_content + extra_txt).strip() - msg_clean_content = (msg_clean_content + extra_txt).strip() - - values = { - 'timestamp': timestamp, - 'server_id': server_id, - 'channel_id': channel_id, - 'user_id': message.author.id, - 'msg_type': msg_type, - 'content': msg_content, - 'clean_content': msg_clean_content, - } - - before_time = timeit.default_timer() - self.con.execute(stmt, values) - self.con.commit() - execution_time = timeit.default_timer() - before_time - self.insert_timing.append(execution_time) - - def purge(self): - before = datetime.today() - timedelta(days=(7 * 3)) - values = dict(before=before) - print('Purging old logs') - cursor = self.con.execute(DELETE_BEFORE_QUERY, values) - self.con.commit() - print('Purged', cursor.rowcount) diff --git a/stickers/__init__.py b/stickers/__init__.py deleted file mode 100644 index bc2f0704..00000000 --- a/stickers/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .stickers import * - - -def setup(bot): - bot.add_cog(Stickers(bot)) diff --git a/stickers/stickers.py b/stickers/stickers.py deleted file mode 100644 index c6bf7683..00000000 --- a/stickers/stickers.py +++ /dev/null @@ -1,177 +0,0 @@ -import re -from collections import defaultdict - -import discord -from redbot.core import checks -from redbot.core import commands -from redbot.core.bot import Red -from redbot.core.utils.chat_formatting import box, pagify - -from rpadutils import CogSettings - -STICKER_COG = None - - -async def is_sticker_admin_check(ctx): - return STICKER_COG.settings.check_admin(ctx.author.id) or await ctx.bot.is_owner(ctx.author) - - -def is_sticker_admin(): - return commands.check(is_sticker_admin_check) - - -class Stickers(commands.Cog): - """Sticker commands.""" - - def __init__(self, bot: Red, *args, **kwargs): - super().__init__(*args, **kwargs) - self.bot = bot - self.settings = StickersSettings("stickers") - - global STICKER_COG - STICKER_COG = self - - @commands.group() - @is_sticker_admin() - async def sticker(self, context): - """Global stickers.""" - - @sticker.command() - @is_sticker_admin() - async def add(self, ctx, command: str, *, text): - """Adds a sticker - - Example: - !stickers add "whale happy" link_to_happy_whale - """ - command = command.lower() - if command in self.bot.all_commands.keys(): - await self.bot.say("That is already a standard command.") - return - - self.settings.update_sticker(command, text) - await ctx.send("Sticker successfully added.") - - @sticker.command() - @is_sticker_admin() - async def delete(self, ctx, command: str): - """Deletes a sticker - - Example: - !stickers delete "whale happy" """ - command = command.lower() - if command in self.settings.get_stickers(): - self.settings.rm_sticker(command) - await ctx.send("Sticker successfully deleted.") - else: - await ctx.send("Sticker doesn't exist.") - - @commands.command() - async def stickers(self, ctx): - """Shows all stickers""" - if not self.settings.get_stickers(): - await ctx.send("There are no stickers yet") - return - - stickers = list(self.settings.get_stickers()) - - prefixes_list = defaultdict(list) - other_list = list() - - for c in stickers: - m = re.match(r'^(.+)[ ](.+)$', c) - if m: - grp = m.group(1) - prefixes_list[grp].append(m.group(2)) - else: - other_list.append(c) - - msg = "Stickers:\n" - for cmd in sorted(other_list): - msg += " {}{}\n".format(ctx.prefix, cmd) - - msg += "\nSticker Packs:\n" - - for prefix in sorted(prefixes_list): - msg += " {}{} [...]\n ".format(ctx.prefix, prefix) - - for suffix in sorted(prefixes_list[prefix]): - msg += " {}".format(suffix) - msg += "\n\n" - - for page in pagify(msg): - await msg.author.send(box(page)) - - @sticker.command() - @checks.is_owner() - async def addadmin(self, ctx, user: discord.Member): - """Adds a user to the stickers admin""" - self.settings.add_admin(user.id) - await ctx.send("done") - - @sticker.command() - @checks.is_owner() - async def rmadmin(self, ctx, user: discord.Member): - """Removes a user from the stickers admin""" - self.settings.rm_admin(user.id) - await ctx.send("done") - - @commands.Cog.listener("on_message") - async def check_for_sticker_request(self, message): - if len(message.content) < 2: - return - - prefix = (await self.bot.get_prefix(message))[0] - - cmdlist = self.settings.get_stickers() - image_url = None - cmd = message.content[len(prefix):] - if cmd in cmdlist.keys(): - image_url = cmdlist[cmd] - elif cmd.lower() in cmdlist.keys(): - image_url = cmdlist[cmd.lower()] - - if image_url: - footer_text = message.content + ' posted by ' + message.author.name - embed = discord.Embed().set_image(url=image_url).set_footer(text=footer_text) - await message.channel.send(embed=embed) - await message.delete() - - -class StickersSettings(CogSettings): - def make_default_settings(self): - config = { - 'admins': [], - 'c_commands': {} - } - return config - - def admins(self): - return self.bot_settings['admins'] - - def check_admin(self, user_id): - admins = self.admins() - return user_id in admins - - def add_admin(self, user_id): - admins = self.admins() - if user_id not in admins: - admins.append(user_id) - self.save_settings() - - def rm_admin(self, user_id): - admins = self.admins() - if user_id in admins: - admins.remove(user_id) - self.save_settings() - - def get_stickers(self): - return self.bot_settings['c_commands'] - - def update_sticker(self, key, value): - self.bot_settings['c_commands'][key] = value - self.save_settings() - - def rm_sticker(self, key): - self.bot_settings['c_commands'].pop(key, None) - self.save_settings() diff --git a/streamcopy/__init__.py b/streamcopy/__init__.py deleted file mode 100644 index 2c48bb04..00000000 --- a/streamcopy/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from .streamcopy import * - - -def setup(bot): - n = StreamCopy(bot) - bot.add_listener(n.check_stream, "on_member_update") - bot.loop.create_task(n.refresh_stream()) - bot.add_cog(n) diff --git a/streamcopy/streamcopy.py b/streamcopy/streamcopy.py deleted file mode 100644 index d50e9c58..00000000 --- a/streamcopy/streamcopy.py +++ /dev/null @@ -1,194 +0,0 @@ -import asyncio -import random -import traceback - -import discord -from redbot.core import checks -from redbot.core import commands -from redbot.core.bot import Red -from redbot.core.utils.chat_formatting import inline, box - -from rpadutils import CogSettings, get_role, get_role_from_id - - -class StreamCopy(commands.Cog): - def __init__(self, bot: Red, *args, **kwargs): - super().__init__(*args, **kwargs) - self.bot = bot - self.settings = StreamCopySettings("streamcopy") - self.current_user_id = None - - async def refresh_stream(self): - await self.bot.wait_until_ready() - while self == self.bot.get_cog('StreamCopy'): - try: - await self.do_refresh() - await self.do_ensure_roles() - except Exception as e: - traceback.print_exc() - - await asyncio.sleep(60 * 3) - print("done refresh_stream") - - @commands.group() - @checks.mod_or_permissions(manage_guild=True) - async def streamcopy(self, ctx): - """Utilities for reacting to users gaining/losing streaming status.""" - - @streamcopy.command() - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def setStreamerRole(self, ctx, *, role_name: str): - try: - role = get_role(ctx.guild.roles, role_name) - except: - await ctx.send(inline('Unknown role')) - return - - self.settings.set_streamer_role(ctx.guild.id, role.id) - await ctx.send(inline('Done. Make sure that role is below the bot in the hierarchy')) - - @streamcopy.command() - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def clearStreamerRole(self, ctx): - self.settings.clear_streamer_role(ctx.guild.id) - await self.bot.say(inline('Done')) - - @streamcopy.command(name="adduser") - @checks.is_owner() - async def addUser(self, ctx, user: discord.User): - self.settings.add_user(user.id) - await ctx.send(inline('Done')) - - @streamcopy.command(name="rmuser") - @checks.is_owner() - async def rmUser(self, ctx, user: discord.User): - self.settings.rm_user(user.id) - await ctx.send(inline('Done')) - - @streamcopy.command(name="list") - @checks.is_owner() - async def list(self, ctx): - user_ids = self.settings.users().keys() - members = {x.id: x for x in self.bot.get_all_members() if x.id in user_ids} - - output = "Users:" - for m_id, m in members.items(): - output += "\n({}) : {}".format('+' if self.is_playing(m) else '-', m.name) - - await ctx.send(box(output)) - - @streamcopy.command(name="refresh") - @checks.is_owner() - async def refresh(self, ctx): - other_stream = await self.do_refresh() - if other_stream: - await ctx.send(inline('Updated stream')) - else: - await ctx.send(inline('Could not find a streamer')) - - async def check_stream(self, before, after): - streamer_role_id = self.settings.get_streamer_role(before.guild.id) - if streamer_role_id: - await self.ensure_user_streaming_role(after.guild, streamer_role_id, after) - - try: - tracked_users = self.settings.users() - if before.id not in tracked_users: - return - - if self.is_playing(after): - await self.copy_playing(after.activity) - return - - await self.do_refresh() - except Exception as ex: - print("Stream checking failed", ex) - - async def ensure_user_streaming_role(self, server, streamer_role_id: discord.Role, user: discord.Member): - user_is_playing = self.is_playing(user) - try: - streamer_role = get_role_from_id(self.bot, server, streamer_role_id) - user_has_streamer_role = streamer_role in user.roles - if user_is_playing and not user_has_streamer_role: - await user.add_roles(streamer_role) - elif not user_is_playing and user_has_streamer_role: - await user.remove_roles(streamer_role) - except Exception as ex: - pass - - async def do_refresh(self): - other_stream = self.find_stream() - if other_stream: - await self.copy_playing(other_stream) - else: - await self.bot.change_presence(activity=None) - return other_stream - - async def do_ensure_roles(self): - servers = self.bot.guilds - for server in servers: - streamer_role_id = self.settings.get_streamer_role(server.id) - if not streamer_role_id: - continue - for member in server.members: - await self.ensure_user_streaming_role(member.guilds, streamer_role_id, member) - - def find_stream(self): - user_ids = self.settings.users().keys() - members = {x.id: x for x in self.bot.get_all_members() if x.id in user_ids and self.is_playing(x)} - games = [x.activity for x in members.values()] - random.shuffle(games) - return games[0] if len(games) else None - - def is_playing(self, member: discord.Member): - return member and member.activity and member.activity.type == 1 and member.activity.url - - async def copy_playing(self, stream: discord.Streaming): - new_stream = discord.Game(name=stream.name, url=stream.url, type=stream.type) - await self.bot.change_presence(activity=new_stream) - - -class StreamCopySettings(CogSettings): - def make_default_settings(self): - config = { - 'users': {}, - 'servers': {} - } - return config - - def users(self): - return self.bot_settings['users'] - - def add_user(self, user_id): - users = self.users() - users[user_id] = {'priority': 100} # This is for stupid legacy reasons - self.save_settings() - - def rm_user(self, user_id): - users = self.users() - if user_id in users: - users.pop(user_id) - self.save_settings() - - def get_guild(self, guild_id): - guilds = self.bot_settings['servers'] - if guild_id not in guilds: - guilds[guild_id] = {} - return guilds[guild_id] - - def set_streamer_role(self, server_id, role_id): - server = self.get_guild(server_id) - server['role'] = role_id - self.save_settings() - - def get_streamer_role(self, server_id): - server = self.get_guild(server_id) - return server.get('role', None) - - def clear_streamer_role(self, server_id): - server = self.get_guild(server_id) - if 'role' in server: - server.pop('role') - self.save_settings() diff --git a/tempcog/__init__.py b/tempcog/__init__.py new file mode 100644 index 00000000..239b25f0 --- /dev/null +++ b/tempcog/__init__.py @@ -0,0 +1,8 @@ +from .tempcog import TempCog + +__red_end_user_data_statement__ = "No personal data is stored." + + +async def setup(bot): + n = TempCog(bot) + await bot.add_cog(n) diff --git a/tempcog/info.json b/tempcog/info.json new file mode 100644 index 00000000..24c66a83 --- /dev/null +++ b/tempcog/info.json @@ -0,0 +1,21 @@ +{ + "author": [ + "Aradia Megido#2552" + ], + "description": "Holds temporary things that will be removed later.", + "end_user_data_statement": "No personal data is stored.", + "install_msg": "I don't even know what I'm supposed to put in these.", + "short": "Temporary Utilities", + "min_bot_version": "3.4.0", + "max_bot_version": "0.0.0", + "min_python_version": [ + 3, + 8, + 1 + ], + "hidden": true, + "disabled": false, + "required_cogs": {}, + "requirements": [], + "tags": [] +} diff --git a/tempcog/tempcog.py b/tempcog/tempcog.py new file mode 100644 index 00000000..224cc1eb --- /dev/null +++ b/tempcog/tempcog.py @@ -0,0 +1,46 @@ +import logging +from io import BytesIO + +from redbot.core import Config, commands + +logger = logging.getLogger('red.padbot-cogs.tempcog') + + +class TempCog(commands.Cog): + """Temporary Utilities.""" + + def __init__(self, bot, *args, **kwargs): + super().__init__(*args, **kwargs) + self.bot = bot + + self.config = Config.get_conf(self, identifier=7379306) + + async def red_get_data_for_user(self, *, user_id): + """Get a user's personal data.""" + data = "No data is stored for user with ID {}.\n".format(user_id) + return {"user_data.txt": BytesIO(data.encode())} + + async def red_delete_data_for_user(self, *, requester, user_id): + """Delete a user's personal data. + + No personal data is stored in this cog. + """ + return + + @commands.Cog.listener('on_member_join') + async def mod_user_join(self, member): + if member.guild.id == 89391974502649856: + await self.bot.get_channel(263512015635611650).send( + "{0.mention} Join name: {0.name} ({0.id})" + " joined the server. Created: {0.created_at}".format(member)) + + if member.guild.id == 435913115750629377: + await self.bot.get_channel(647601840891887641).send( + "{0.mention} Join name: {0.name} ({0.id})" + " joined the server. Created: {0.created_at}".format(member)) + + if member.guild.id == 243014364129525760: + await self.bot.get_channel(362777217349976065).send( + "{0.mention} Join name: {0.name} ({0.id})" + " joined the server. Created: {0.created_at}".format(member)) + diff --git a/tests/dbcog/database_context_test.py b/tests/dbcog/database_context_test.py new file mode 100644 index 00000000..38c910c6 --- /dev/null +++ b/tests/dbcog/database_context_test.py @@ -0,0 +1,31 @@ +from tsutils.enums import Server + +from dbcog.database_manager import DBCogDatabase +from dbcog.database_context import DbContext +from dbcog.dungeon_context import DungeonContext +from dbcog.monster_graph import MonsterGraph +from dbcog.models.enum_types import InternalEvoType + +database = DBCogDatabase('S:\\Documents\\Games\\PAD\\dadguide.sqlite') +graph = MonsterGraph(database) +dungeon = DungeonContext(database) +dungeon = DungeonContext(database) +db_context = DbContext(database, graph, dungeon) +get_monster = lambda mid, server=Server.COMBINED: graph.get_monster(mid, server=server) + +assert get_monster(4).is_farmable +assert not get_monster(5156).is_farmable +assert graph.get_base_monster(get_monster(1074)).monster_id == 1073 # evo pandora +assert db_context.get_monsters_by_series(1, server=Server.COMBINED)[0].name_en == 'Tyrra' +assert db_context.get_monsters_by_active(1, server=Server.COMBINED)[0].name_en == 'Tyrra' + +# evo types +assert graph.true_evo_type(get_monster(5392)) == InternalEvoType.Ultimate +assert graph.true_evo_type(get_monster(6337)) == InternalEvoType.SuperReincarnated # sr hades +assert graph.true_evo_type(get_monster(5871)) == InternalEvoType.Pixel # px sakuya +assert graph.true_evo_type(get_monster(1465)) == InternalEvoType.Normal # awoken thoth +assert graph.true_evo_type(get_monster(1464)) == InternalEvoType.Base # base thoth + +# mats +assert len(graph.get_evolution(get_monster(3)).mats) == 2 +assert 153 in graph.get_evolution(get_monster(3)).mats diff --git a/timecog/__init__.py b/timecog/__init__.py deleted file mode 100644 index 76fc7e5c..00000000 --- a/timecog/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from .timecog import * - - -def setup(bot): - n = TimeCog(bot) - bot.add_cog(n) - bot.loop.create_task(n.reminderloop()) diff --git a/timecog/timecog.py b/timecog/timecog.py deleted file mode 100644 index b4007d19..00000000 --- a/timecog/timecog.py +++ /dev/null @@ -1,412 +0,0 @@ -import asyncio -import re -import time -import traceback -from datetime import timedelta, datetime -from dateutil.relativedelta import relativedelta - -import pytz -from redbot.core import commands, Config, checks -from redbot.core.bot import Red -from redbot.core.utils.chat_formatting import inline - -import rpadutils - -tz_lookup = dict([(pytz.timezone(x).localize(datetime.now()).tzname(), pytz.timezone(x)) - for x in pytz.all_timezones]) - -time_at_regeces = [ - r'^\s*(?P\d{4})[-/](?P\d+)[-/](?P\d+) (?P\d+):(?P\d\d) ?(?Ppm|am)? ?(?P.*)$', - r'^\s*(?P\d{4})[-/](?P\d+)[-/](?P\d+) ?(?P.*)$', - r'^\s*(?P\d+)[-/](?P\d+) ?(?P.*)$', - r'^\s*(?P\d+):(?P\d\d) ?(?P\d?pm|am)? ?(?P.*)$', - r'^\s*(?P\d+) ?(?P\d?pm|am) ?(?P.*)$', -] - -time_in_regeces = [ - r'^\s*((?:-?\d+ ?(?:m|h|d|w|y|s)\w* ?)+)\b (.+)$', # One tinstr - r'^\s*((?:-?\d+ ?(?:m|h|d|w|y|s)\w* ?)+)\b\s*\|\s*((?:-?\d+ ?(?:m|h|d|w|y|s)\w* ?)+|now)\b (.*)$', #Unused -] - -DT_FORMAT = "%A, %b %-d, %Y at %-I:%M %p" - - -class TimeCog(commands.Cog): - """Utilities pertaining to time""" - - def __init__(self, bot: Red, *args, **kwargs): - super().__init__(*args, **kwargs) - self.config = Config.get_conf(self, identifier=7173306) - self.config.register_user(reminders=[], tz='') - self.config.register_channel(schedules=[]) - - """ - CONFIG: Config - | USERS: Config - | | REMINDERS: list - | | | REMINDER: tuple - | | | | TIME: int (timestamp) - | | | | TEXT: str - | | | | INTERVAL: Optional[int] - | | TZ: str (tzstr) - | CHANNELS: Config - | | SCHEDULES: list - | | | SCHEDULE: tuple - | | | | START: int (timestamp) - | | | | INTERVAL: int (seconds) - | | | | TEXT: str - """ - - self.bot = bot - - #@commands.group(invoke_without_command=True) - async def schedule(self, ctx, *, text): - raise ValueError() - match = re.search(time_in_regeces[0], text, re.IGNORECASE) - if match: - tinstart = "now" - tinstrs, input = match.groups() - else: - match = re.search(time_in_regeces[1], text, re.IGNORECASE) - if not match: - await ctx.send("Invalid interval") - return - tinstart, tinstrs, input = match.groups() - start = (datetime.utcnow() + tin2tdelta(tinstart)).timestamp() - async with self.config.channel(ctx.channel).schedules() as scs: - scs.append((start, tin2tdelta(tinstrs).seconds, input)) - m = await ctx.send("Schedule created. I will send a message on my next cycle (10s or less)") - await asyncio.sleep(5.) - await m.delete() - - @commands.group(aliases=['remindmeat', 'remindmein'], invoke_without_command=True) - async def remindme(self, ctx, *, time): - """Reminds you to do something at a specified time - - [p]remindme 2020-04-13 06:12 Do something! - [p]remindme 5 weeks Do something! - [p]remindme 4:13 PM Do something! - [p]remindme 2020-05-03 Do something! - [p]remindme 04-13 Do something! - """ - if time is None: - return - - user_tz_str = await self.config.user(ctx.author).tz() - user_timezone = tzstr_to_tz(user_tz_str or 'UTC') - - for ar in time_at_regeces: - match = re.search(ar, time, re.IGNORECASE) - if not match: - continue - match = match.groupdict() - - if not user_tz_str: - await ctx.send( - "Please configure your personal timezone with `{0.clean_prefix}settimezone` first.".format(ctx)) - return - - now = datetime.now(tz=user_timezone) - defaults = { - 'year': now.year, - 'month': now.month, - 'day': now.day, - 'hour': now.hour, - 'minute': now.minute, - 'merid': 'NONE' - } - defaults.update({k: v for k, v in match.items() if v}) - input = defaults.pop('input') - for key in defaults: - if key not in ['merid']: - defaults[key] = int(defaults[key]) - if defaults['merid'] == 'pm' and defaults['hour'] <= 12: - defaults['hour'] += 12 - elif defaults['merid'] == 'NONE' and defaults['hour'] < now.hour: - defaults['hour'] += 12 - if defaults['hour'] >= 24: - defaults['day'] += int(defaults['hour'] // 24) - defaults['hour'] = defaults['hour'] % 24 - del defaults['merid'] - try: - rmtime = user_timezone.localize(datetime(**defaults)) - except ValueError as e: - await ctx.send(inline(str(e).capitalize())) - return - if rmtime < now: - rmtime += timedelta(days=1) - rmtime = rmtime.astimezone(pytz.utc).replace(tzinfo=None) - break - else: - ir = time_in_regeces[0] - match = re.search(ir, time, re.IGNORECASE) - if not match: # Only use the first one - raise commands.UserFeedbackCheckFailure("Invalid time string: " + time) - tinstrs, input = match.groups() - rmtime = datetime.utcnow() - try: - rmtime += tin2tdelta(tinstrs) - except OverflowError: - raise commands.UserFeedbackCheckFailure( - inline("That's way too far in the future! Please keep it in your lifespan!")) - - if rmtime < (datetime.utcnow() - timedelta(seconds=1)): - raise commands.UserFeedbackCheckFailure(inline("You can't set a reminder in the past! If only...")) - - async with self.config.user(ctx.author).reminders() as rms: - rms.append((rmtime.timestamp(), input)) - - response = "I will tell you " + format_rm_time(rmtime, input, user_timezone) - if not user_tz_str: - response += '. Configure your personal timezone with `{0.clean_prefix}settimezone` for accurate times.'.format( - ctx) - await ctx.send(response) - - @remindme.command(hidden=True) - async def now(self, ctx, *, input): - await ctx.author.send(input) - - @remindme.command() - @checks.is_owner() #Command is unfinished - async def every(self, ctx, *, text): - await ctx.send("Test") - match = re.search(time_in_regeces[1], text, re.IGNORECASE) - if match: - tinstrs, tinstart, input = match.groups() - else: - match = re.search(time_in_regeces[0], text, re.IGNORECASE) - if not match: - await ctx.send("Invalid interval") - return - tinstart = "now" - tinstrs, input = match.groups() - print(tinstrs, tinstart, input) - start = (datetime.utcnow() + tin2tdelta(tinstart)).timestamp() - async with self.config.channel(ctx.channel).schedules() as scs: - scs.append((start, tin2tdelta(tinstrs).seconds, input)) - m = await ctx.send("Schedule created. I will send a message on my next cycle (10s or less)") - await asyncio.sleep(5.) - await m.delete() - - @remindme.command(aliases=["list"]) - async def get(self, ctx): - """Get a list of all pending reminders""" - rlist = sorted((await self.config.user(ctx.author).reminders()), key=lambda x: x[0]) - if not rlist: - await ctx.send(inline("You have no pending reminders!")) - return - tz = tzstr_to_tz(await self.config.user(ctx.author).tz()) - o = [] - for c, (timestamp, input) in enumerate(rlist): - o.append(str(c + 1) + ": " + format_rm_time(datetime.fromtimestamp(float(timestamp)), input, tz)) - o = "```\n" + '\n'.join(o) + "\n```" - await ctx.send(o) - - @remindme.command() - async def remove(self, ctx, no: int): - """Remove a specific pending reminder""" - rlist = sorted(await self.config.user(ctx.author).reminders(), key=lambda x: x[0]) - if len(rlist) < no: - await ctx.send(inline("There is no reminder #{}".format(no))) - return - async with self.config.user(ctx.author).reminders() as rms: - rms.remove(rlist[no - 1]) - await ctx.send(inline("Done")) - - @remindme.command() - async def purge(self, ctx): - """Delete all pending reminders.""" - await self.config.user(ctx.author).reminders.set([]) - await ctx.send(inline("Done")) - - @commands.command(aliases=['settz']) - async def settimezone(self, ctx, tzstr): - """Set your timezone.""" - try: - v = tzstr_to_tz(tzstr) - await self.config.user(ctx.author).tz.set(tzstr) - await ctx.send(inline("Set personal timezone to {} ({})".format(str(v), get_tz_name(v)))) - except IOError as e: - await ctx.send(inline("Invalid tzstr: " + tzstr)) - - async def reminderloop(self): - await self.bot.wait_until_ready() - async for _ in rpadutils.repeating_timer(10, lambda: self == self.bot.get_cog('TimeCog')): - urs = await self.config.all_users() - chs = await self.config.all_channels() - now = datetime.utcnow() - for u in urs: - for rm in urs[u]['reminders']: - if datetime.fromtimestamp(float(rm[0])) < now: - async with self.config.user(self.bot.get_user(u)).reminders() as rms: - rms.remove(rm) - await self.bot.get_user(u).send(rm[1]) - for c in chs: - for sc in chs[c]['schedules']: - if datetime.fromtimestamp(float(sc[0])) < now: - async with self.config.channel(self.bot.get_channel(c)).schedules() as sco: - scind = sco.index(sc) - sco[scind][0] += sco[scind][1] - print(sco, scind, datetime.utcnow()) - await self.bot.get_channel(c).send(sc[2]) - - @commands.command() - async def time(self, ctx, *, tz: str): - """Displays the current time in the supplied timezone""" - try: - tz_obj = tzstr_to_tz(tz) - except Exception as e: - await ctx.send("Failed to parse tz: " + tz) - return - - now = datetime.now(tz_obj) - msg = "The time in " + now.strftime('%Z') + " is " + fmt_time_short(now).strip() - await ctx.send(inline(msg)) - - @commands.command() - async def timeto(self, ctx, tz: str, *, time: str): - """Compute the time remaining until the [timezone] [time]""" - try: - tz_obj = tzstr_to_tz(tz) - except Exception as e: - await ctx.send("Failed to parse tz: " + tz) - return - - try: - time_obj = timestr_to_time(time) - except Exception as e: - await ctx.send("Failed to parse time: " + time) - return - - now = datetime.now(tz_obj) - req_time = now.replace(hour=time_obj.tm_hour, minute=time_obj.tm_min) - - if req_time < now: - req_time = req_time + timedelta(days=1) - delta = req_time - now - - msg = ("There are " + fmt_hrs_mins(delta.seconds).strip() + - " until " + time.strip() + " in " + now.strftime('%Z')) - await ctx.send(inline(msg)) - - -def timestr_to_time(timestr): - timestr = timestr.replace(" ", "") - try: - return time.strptime(timestr, "%H:%M") - except: - pass - try: - return time.strptime(timestr, "%I:%M%p") - except: - pass - try: - return time.strptime(timestr, "%I%p") - except: - pass - raise commands.UserFeedbackCheckFailure("Invalid Time: " + timestr) - - -def fmt_hrs_mins(seconds): - hours = seconds // 3600 - minutes = (seconds % 3600) // 60 - return '{}hrs {}mins'.format(int(hours), int(minutes)) - - -def fmt_time_short(dt): - return dt.strftime("%I:%M %p") - - -def tzstr_to_tz(tz): - tz = tz.lower().strip() - if tz in ['edt', 'est', 'et']: - tz = 'America/New_York' - elif tz in ['mdt', 'mst', 'mt']: - tz = 'America/North_Dakota/Center' - elif tz in ['pdt', 'pst', 'pt']: - tz = 'America/Los_Angeles' - elif tz in ['jp', 'jt', 'jst']: - return tz_lookup['JST'] - elif tz.upper() in tz_lookup: - return tz_lookup[tz.upper()] - else: - for tzo in pytz.all_timezones: - if tz.lower() in tzo.lower().split("/"): - tz = tzo - break - else: - for tzo in pytz.all_timezones: - if tz.lower() in tzo: - tz = tzo - break - try: - return pytz.timezone(tz) - except Exception as e: - raise commands.UserFeedbackCheckFailure("Invalid timezone: " + tz) - - -def tin2tdelta(tinstr): - if tinstr.lower().strip() == "now": - return timedelta(0) - tins = re.findall(r'(-?\d+) ?([a-z]+) ?', tinstr.lower()) - o = timedelta() - for tin, unit in tins: - try: - tin = int(tin) - if unit[:2] == 'mo': - o += relativedelta(months=+1) - elif unit[0] == 'm': - o += timedelta(minutes=tin) - elif unit[0] == 'h': - o += timedelta(hours=tin) - elif unit[0] == 'd': - o += timedelta(days=tin) - elif unit[0] == 'w': - o += timedelta(weeks=tin) - elif unit[0] == 'y': - o += relativedelta(years=+1) - elif unit[0] == 's': - raise commands.UserFeedbackCheckFailure( - "We aren't exact enough to use seconds! If you need that precision, try this: https://www.timeanddate.com/timer/") - else: - raise commands.UserFeedbackCheckFailure(inline( - "Invalid unit: {}\nPlease use minutes, hours, days, weeks, months, or, if you're feeling especially zealous, years.".format( - unit))) - except OverflowError: - raise commands.UserFeedbackCheckFailure(inline("Come on... Be reasonable :/")) - return o - - -def ydhm(seconds): - y, seconds = divmod(seconds, 60 * 60 * 24 * 365) - d, seconds = divmod(seconds, 60 * 60 * 24) - h, seconds = divmod(seconds, 60 * 60) - m, seconds = divmod(seconds, 60) - y, d, h, m = [int(ydhm) for ydhm in (y, d, h, m)] - ydhm = [] - if y: ydhm.append("{} yr" .format(y) + ("s" if y > 1 else '')) - if d: ydhm.append("{} day".format(d) + ("s" if d > 1 else '')) - if h: ydhm.append("{} hr" .format(h) + ("s" if h > 1 else '')) - if m: ydhm.append("{} min".format(m) + ("s" if m > 1 else '')) - return " ".join(ydhm) or "<1 minute" - - -def format_rm_time(rmtime, input, D_TZ): - return "'{}' on {} {} ({} from now)".format( - input, - D_TZ.fromutc(rmtime).strftime(DT_FORMAT), - get_tz_name(D_TZ, rmtime), - ydhm((rmtime - datetime.utcnow()).total_seconds() + 2) - ) - - -def get_tz_name(tz, dt=None): - if dt is None: - dt = datetime.utcnow() - else: - dt = dt.replace(tzinfo=None) - tzname = tz.tzname(datetime(year=dt.year, month=1, day=1)) - tznowname = tz.tzname(dt) - if tzname != tznowname and tznowname: - return "{} ({})".format(tzname, tznowname) - return tzname diff --git a/translate/__init__.py b/translate/__init__.py deleted file mode 100644 index 3363b07e..00000000 --- a/translate/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .translate import Translate - - -def setup(bot): - bot.add_cog(Translate(bot)) diff --git a/translate/translate.py b/translate/translate.py deleted file mode 100644 index 0eb1462a..00000000 --- a/translate/translate.py +++ /dev/null @@ -1,70 +0,0 @@ -import discord -from googleapiclient.discovery import build -from redbot.core import checks -from redbot.core import commands -from redbot.core.bot import Red -from redbot.core.utils.chat_formatting import inline - -from rpadutils import CogSettings - - -class Translate(commands.Cog): - """Translation utilities.""" - - def __init__(self, bot: Red, *args, **kwargs): - super().__init__(*args, **kwargs) - self.bot = bot - self.settings = TranslateSettings("translate") - - self.service = None - self.trySetupService() - - def trySetupService(self): - api_key = self.settings.get_key() - if api_key: - self.service = build('translate', 'v2', developerKey=api_key) - - @commands.group() - @checks.is_owner() - async def translate(self, context): - """Translation utilities.""" - - @commands.command(aliases=['jaus', 'jpen', 'jpus']) - async def jaen(self, ctx, *, query): - """Translates from Japanese to English""" - if not self.service: - await ctx.send(inline('Set up an API key first!')) - return - - em = self.translate_to_embed(query) - await ctx.send(embed=em) - - def translate_jp_en(self, query): - result = self.service.translations().list(source='ja', target='en', format='text', q=query).execute() - return result.get('translations')[0].get('translatedText') - - def translate_to_embed(self, query): - translation = self.translate_jp_en(query) - return discord.Embed(description='**Original**\n`{}`\n\n**Translation**\n`{}`'.format(query, translation)) - - @translate.command() - @checks.is_owner() - async def setkey(self, ctx, api_key): - """Sets the google api key.""" - self.settings.set_key(api_key) - await ctx.send("done") - - -class TranslateSettings(CogSettings): - def make_default_settings(self): - config = { - 'google_api_key': '' - } - return config - - def get_key(self): - return self.bot_settings.get('google_api_key') - - def set_key(self, api_key): - self.bot_settings['google_api_key'] = api_key - self.save_settings() diff --git a/trutils/__init__.py b/trutils/__init__.py deleted file mode 100644 index 29d0b69e..00000000 --- a/trutils/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .trutils import * - - -def setup(bot): - bot.add_cog(TrUtils(bot)) diff --git a/trutils/trutils.py b/trutils/trutils.py deleted file mode 100644 index 5a0cdb97..00000000 --- a/trutils/trutils.py +++ /dev/null @@ -1,499 +0,0 @@ -import asyncio -import datetime -import re - -import discord -from redbot.core.bot import Red - -try: - from google.cloud import vision -except: - print('google cloud vision not found, some features unavailable') - -from redbot.core import checks, modlog -from redbot.core import commands -from redbot.core.utils.chat_formatting import inline, box, pagify - -from rpadutils import CogSettings - -GETMIRU_HELP = """ -The new public Miru is open for invite to any server: personal, private, secret-handshake-entry-only, etc -Unlike the private Miru used by larger community servers, public Miru has lower stability requirements, -so I will install a variety of random entertainment plugins. - -To invite public Miru to your server, use the following link: -https://discordapp.com/oauth2/authorize?client_id=296443771229569026&scope=bot - -The following commands might come in handy: -`{0.prefix}modhelp` - information on how to set up Miru's moderation commands -`{0.prefix}userhelp` - a user-focused guide to Miru's commands -`{0.prefix}help` - the full list of Miru commands - -If you want to be notified of updates to Miru, suggest features, or ask for help, join the Miru Support server: -https://discord.gg/zB4QHgn -""" - -USER_HELP = """ -Bot user help -This command gives you an overview of the most commonly used user-focused -commands, with an emphasis on the ones unique to this bot. - -Join the Miru Support Server for info, update, and bot support: -https://discord.gg/zB4QHgn - -Use {0.prefix}help to get a full list of help commands. Execute any command with no -arguments to get more details on how they work. - -Info commands: -{0.prefix}credits some info about the bot -{0.prefix}donate info on how to donate to cover hosting fees -{0.prefix}userhelp this message -{0.prefix}modhelp bot help specifically for mods - -General: -{0.prefix}pad lists the pad-specific global commands -{0.prefix}padfaq lists pad-specific FAQ commands -{0.prefix}boards lists common leader optimal board commands -{0.prefix}glossary looks up a pad term in the glossary -{0.prefix}customcommands lists the custom commands added by the administrators of your server -{0.prefix}memes works the same way, but is restricted per-server to a privileged memer-only group -{0.prefix}serverinfo stats for the current server -{0.prefix}userinfo stats for a specific user - -Monster Info: -{0.prefix}id search for a monster by ID, full name, nickname, etc -{0.prefix}helpid gets more info on how monster lookup works, including the nickname submission link -{0.prefix}evolist list all the evolutions for a monster, tabbed -{0.prefix}pic display the image for a monster - -Profile: -Miru will store your personal PAD details, and provide them on request. -Use the series of commands starting with {0.prefix}profile to configure your own profile. - -Use one of the following commands to retrieve data. -{0.prefix}idme print your profile to the current channel -{0.prefix}idfor get profile data for a specific user -{0.prefix}idto have Miru DM your profile to a user -{0.prefix}profile search search the list of configured (visible) profiles - -Time conversion: -{0.prefix}time get the current time in a different timezone -{0.prefix}timeto calculate the how long until another time in another timezone - -Translation: -{0.prefix}[jpen|jpus|jaen|jaus] translate text from japanese to english -""" - -MOD_HELP = """ -Bot Moderator Help -~~~~~~~~~~~~~~~~~~~~~~ - -Miru is a set of plugins inside the Red Discord bot, running on discord.py. There -are some custom ones, but a lot of them are generic to all Red Discord bots, so -things you've used elsewhere will probably also work here. - -If there is a feature you're missing, let me know and I can check to see if it's -already available in some public plugin. If not, and I think it's valuable, I might -write it. - -~~~~~~~~~~~~~~~~~~~~~~ - -Check out the {0.prefix}help command from inside your server. You'll see a wider list of -commands than normal users do. - -If you've just added Miru to your server, start with the {0.prefix}modset command. You -might want to configure an Admin and a Mod role (they can be the same thing). - -~~~~~~~~~~~~~~~~~~~~~~ -Interesting features -~~~~~~~~~~~~~~~~~~~~~~ - -Self applied roles: -You can configure which roles a user can add to themself using {0.prefix}selfrole via {0.prefix}adminset - -Message logs: -Discord doesn't save deleted/edited messages anywhere. Using {0.prefix}exlog you can pull -messages for a user, channel, or search for a term. - -Auto Moderation: -The {0.prefix}automod2 command allows you to configure a set of rules (defined as regular expressions) -that match messages. You can then apply these rules as either a blacklist or a whitelist to -a specific channel. This allows you to force users to format their messages a specific way, -or to prevent them from saying certain things (the bot deletes violators, and notifies them -via DM). - -Bad user tools: -Allows you to specify a set of roles that are applied as punishments to users, generally -restricting them from seeing or speaking in certain channels. If a punishment role is -applied to a user, the last 10 things they said (and where they said it) are recorded, and -a strike is added to their record. - -You can configure a channel where Miru will log when these moderation events occur, and ping -@here asking for an explanation. She will also track when a user with a strike leaves the -server, and when they rejoin the server (as this is generally done to evade negative roles). - -Custom commands: -Miru supports three types of custom commands, you can find the list of associated commands via {0.prefix}help. -* CustomCommands: Added by server mods, executable by anyone -* Memes: Added by server mods, executable only by people with a specific Role (configured by mods) -* Pad: Added by Miru PAD admins and executable by users in any server - -Limiting command execution: -The '{0.prefix}p' command can be used to prevent users from executing specific commands on the server, -in specific channels, or unless they have specific roles. Read the documentation carefully. -""" - - -class TrUtils(commands.Cog): - def __init__(self, bot: Red, *args, **kwargs): - super().__init__(*args, **kwargs) - self.bot = bot - self.settings = TrUtilsSettings("trutils") - - @commands.command() - async def revertname(self, ctx): - """Unsets your nickname""" - await ctx.author.edit(nick=None) - await ctx.send(inline('Done')) - - @commands.command() - @checks.is_owner() - async def loadallcogs(self, ctx): - cogs = ['RpadUtils', 'AutoMod2', 'ChannelMod', 'Donations', 'FancySay', 'Memes', - 'PadBoard', 'Profile', 'Stickers', 'StreamCopy', 'Translate', 'VoiceRole', - 'Dadguide', 'PadGlobal', 'PadInfo'] - - owner_cog = self.bot.get_cog('Core') - - for cog_name in cogs: - cog = self.bot.get_cog(cog_name) - if cog is None: - await ctx.send('{} not loaded, trying to load it...'.format(cog_name)) - try: - await ctx.invoke(owner_cog.load, cog_name.lower()) - except Exception as e: - await ctx.send(box("Loading cog failed: {}: {}".format(e.__class__.__name__, str(e)))) - await ctx.send('Done!') - - @commands.command(hidden=True) - async def traceback2(self, ctx: commands.Context, public: bool = False): - """Sends to the owner the last command exception that has occurred - - If public (yes is specified), it will be sent to the chat instead""" - if ctx.author.id not in [144250811315257344, 86605480876601344]: - return - - if not public: - destination = ctx.author - else: - destination = ctx.channel - - if self.bot._last_exception: - for page in pagify(self.bot._last_exception, shorten_by=10): - await destination.send(box(page, lang="py")) - else: - await ctx.send("No exception has occurred yet") - - @commands.command() - async def getmiru(self, ctx): - """Tells you how to get Miru into your server""" - for page in pagify(GETMIRU_HELP.format(ctx), delims=['\n'], shorten_by=8): - await ctx.author.send(box(page)) - - @commands.command() - async def userhelp(self, ctx): - """Shows a summary of the useful user features""" - for page in pagify(USER_HELP.format(ctx), delims=['\n'], shorten_by=8): - await ctx.author.send(box(page)) - - @commands.command() - @checks.mod_or_permissions(manage_guild=True) - async def modhelp(self, ctx): - """Shows a summary of the useful moderator features""" - for page in pagify(MOD_HELP.format(ctx), delims=['\n'], shorten_by=8): - await ctx.author.send(box(page)) - - @commands.command() - async def credits(self, ctx): - """Shows info about this bot""" - author_repo = "https://github.com/Twentysix26" - red_repo = author_repo + "/Red-DiscordBot" - rpad_invite = "https://discord.gg/pad" - - about = ( - "This is an instance of [the Red Discord bot]({}), " - "use the 'info' command for more info. " - "The various PAD related cogs were created by tactical_retreat. " - "Massive overhaul of the bot to Red v3 by Aradia Megido. " - "This bot was created for the [PAD Community Server Discord]({}) but " - "is available for other servers on request." - "".format(red_repo, rpad_invite)) - - baby_miru_url = "http://www.pixiv.net/member_illust.php?illust_id=57613867&mode=medium" - baby_miru_author = "BOW @ Pixiv" - cute_miru_url = "https://www.dropbox.com/s/0wlfx3g4mk8c8bg/Screenshot%202016-12-03%2018.39.37.png?dl=0" - cute_miru_author = "Pancaaake18 on discord" - bot_miru_url = "https://puu.sh/urTm8/c3bdf993bd.png" - bot_miru_author = "graps on discord" - avatar = ( - "Bot avatars supplied by:\n" - "\t[Baby Miru]({}): {}\n" - "\t[Cute Miru]({}): {}\n" - "\t[Bot Miru]({}): {}" - "".format(baby_miru_url, baby_miru_author, - cute_miru_url, cute_miru_author, - bot_miru_url, bot_miru_author)) - - using = ( - "You can use `{0.prefix}help` to get a full list of commands.\n" - "Use `{0.prefix}userhelp` to get a summary of useful user features.\n" - "Use `{0.prefix}modhelp` to get info on moderator-only features." - ) - - embed = discord.Embed() - embed.add_field(name="Instance owned by", value='tactical_retreat') - embed.add_field(name="About the bot", value=about, inline=False) - embed.add_field(name="Using the bot", value=using.format(ctx), inline=False) - embed.add_field(name="Avatar credits", value=avatar, inline=False) - embed.set_thumbnail(url=self.bot.user.avatar_url) - - try: - await ctx.send(embed=embed) - except discord.HTTPException: - await ctx.send("I need the `Embed links` permission to send this") - - @commands.command(hidden=True) - @checks.is_owner() - async def supersecretdebug(self, ctx, *, code): - """Executes superdebug and then deletes the message that triggered it. - - This is useful if you want to make the bot appear to say or do something that can't - otherwise be easily done. - """ - await self._superdebug(ctx, code=code) - await ctx.message.delete() - - @commands.command(hidden=True) - @checks.is_owner() - async def superdebug(self, ctx, *, code): - """Evaluates code, with a helper for asynchronous results (like ctx.send()). - - If you just want to evaluate variables, repl is a better alternative. - """ - await self._superdebug(ctx, code=code) - - async def _superdebug(self, ctx, *, code): - def check(m): - if m.content.strip().lower() == "more": - return True - - author = ctx.author - channel = ctx.channel - - code = code.strip('` ') - result = None - - global_vars = globals().copy() - global_vars['bot'] = self.bot - global_vars['ctx'] = ctx - global_vars['message'] = ctx.message - global_vars['author'] = ctx.author - global_vars['channel'] = ctx.channel - global_vars['guild'] = ctx.guild - - local_vars = locals().copy() - local_vars['to_await'] = list() - - try: - eval(compile(code, '', 'exec'), global_vars, local_vars) - to_await = local_vars['to_await'] - except Exception as e: - await ctx.send(box('{}: {}'.format(type(e).__name__, str(e)), - lang="py")) - return - - for result in to_await: - if asyncio.iscoroutine(result): - try: - result = await result - except Exception as e: - await ctx.send(box('{}: {}'.format(type(e).__name__, str(e)), - lang="py")) - else: - await ctx.send(result) - - @commands.command() - @checks.is_owner() - async def superfuckingban(self, ctx, user: discord.User, *, reason: str): - """Really fucking bans someone. - - This will ban a user from every guild that the bot can ban them from. Use with caution. - """ - msg = 'Ban report for {} ({}):'.format(user.name, user.id) - for guild in self.bot.guilds: - try: - ban_list = await guild.bans() - if user.id in [x.user.id for x in ban_list]: - msg += '\n\tUser already banned from {}'.format(guild.name) - continue - except: - msg += '\n\tNot allowed to ban in {}; nothing I can do here'.format(guild.name) - continue - - m = guild.get_member(user.id) - if m is None: - try: - await self.bot.http.ban(user.id, guild.id, 0) - msg += '\n\tUser not in {}; added to hackban'.format(guild.name) - await modlog.create_case(bot=self.bot, - guild=guild, - created_at=datetime.datetime.now(), - action_type="hackban", - moderator=ctx.author, - user=user, - reason='SuperBan by bot owner: {}'.format(reason)) - except Exception as ex: - msg += '\n\tUser not in {}; hackban failed: {}'.format(guild.name, ex) - continue - try: - await m.ban(delete_message_days=0) - msg += '\n\tBanned from {}'.format(guild.name) - await modlog.create_case(bot=self.bot, - guild=guild, - created_at=datetime.datetime.now(), - action_type="ban", - moderator=ctx.author, - user=user, - reason='SuperBan by bot owner: {}'.format(reason)) - except Exception as ex: - msg += '\n\tFailed to ban from {} because {}'.format(guild.name, ex) - - for page in pagify(msg): - await ctx.send(box(page)) - - async def _send_feedback(self, ctx, message: str, feedback_channel, success_message: str): - if feedback_channel is None: - raise commands.UserFeedbackCheckFailure("Feedback channel not set") - - guild = ctx.guild - author = ctx.author - footer = "User ID: " + str(author.id) - - if guild: - source = "from {}".format(guild) - footer += " | Guild ID: " + str(guild.id) - else: - source = "through DM" - - description = "Sent by {} {}".format(author, source) - - e = discord.Embed(description=message) - if author.avatar_url: - e.set_author(name=description, icon_url=author.avatar_url) - else: - e.set_author(name=description) - e.set_footer(text=footer) - - try: - await feedback_channel.send(embed=e) - except: - await ctx.send(inline("I'm unable to deliver your message. Sorry.")) - else: - await ctx.send(inline("Your message has been sent." - " Abusing this feature will result in a blacklist." - + success_message)) - - @commands.command() - @commands.cooldown(1, 60, commands.BucketType.user) - async def feedback(self, ctx, *, message: str): - """Provide feedback on the bot. - - Use this command to provide public feedback on the bot. - """ - feedback_channel = self.bot.get_channel(int(self.settings.get_feedback_channel())) - await self._send_feedback(ctx, message, feedback_channel, - " Join the Miru Server to see any responses ({0.prefix}miruserver).".format(ctx)) - - @commands.command() - @commands.guild_only() - @checks.is_owner() - async def setfeedbackchannel(self, ctx, channel: discord.TextChannel): - """Set the feedback destination channel.""" - self.settings.set_feedback_channel(channel.id) - await ctx.send(inline('Done')) - - @commands.command(aliases=['mamafeedback', 'renifeedback']) - @commands.cooldown(1, 60, commands.BucketType.user) - async def blogfeedback(self, ctx, *, message: str): - """Provide feedback on Reni's blog or translations. - - Use this command to submit feedback on https://pad.protic.site or the JP translations. - """ - feedback_channel = self.bot.get_channel(int(self.settings.get_blog_feedback_channel())) - await self._send_feedback(ctx, message, feedback_channel, - " Join the PDX Server to see any responses ({0.prefix}pdx).".format(ctx)) - - @commands.command() - @commands.guild_only() - @checks.is_owner() - async def setblogfeedbackchannel(self, ctx, channel: discord.TextChannel): - """Set the blog feedback destination channel.""" - self.settings.set_blog_feedback_channel(channel.id) - await ctx.send(inline('Done')) - - @commands.command() - @commands.guild_only() - @checks.mod_or_permissions(manage_guild=True) - async def mentionable(self, ctx, role: discord.Role): - """Toggle the mentionability of a role.""" - try: - new_mentionable = not role.mentionable - await role.edit(mentionable=new_mentionable) - await ctx.send(inline('Role is now {}mentionable'.format('' if new_mentionable else 'un'))) - except Exception as ex: - await ctx.send(inline('Error: failed to alter role')) - - @commands.command() - @checks.is_owner() - async def freload(self, ctx, cmd, *, args=""): - """Run a command after reloading its base cog.""" - full_cmd = "{}{} {}".format(ctx.prefix, cmd, args) - cmd = self.bot.get_command(cmd) - if cmd is None: - await ctx.send("Invalid Command: {}".format(full_cmd)) - return - await self.bot.get_cog("Core").reload(ctx, cmd.cog.__module__.split('.')[-1]) - ctx.message.content = full_cmd - await self.bot.process_commands(ctx.message) - - - -class TrUtilsSettings(CogSettings): - def make_default_settings(self): - config = { - 'servers': {}, - } - return config - - def guilds(self): - return self.bot_settings['servers'] - - def get_guild(self, server_id): - servers = self.guilds() - if server_id not in servers: - servers[server_id] = {} - return servers[server_id] - - def get_feedback_channel(self): - return self.bot_settings.get('feedback_channel') - - def set_feedback_channel(self, channel_id: int): - self.bot_settings['feedback_channel'] = str(channel_id) - self.save_settings() - - def get_blog_feedback_channel(self): - return self.bot_settings.get('blog_feedback_channel') - - def set_blog_feedback_channel(self, channel_id: int): - self.bot_settings['blog_feedback_channel'] = str(channel_id) - self.save_settings() diff --git a/voicerole/__init__.py b/voicerole/__init__.py deleted file mode 100644 index 31a64b83..00000000 --- a/voicerole/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .voicerole import * - - -def setup(bot): - bot.add_cog(VoiceRole(bot)) diff --git a/voicerole/voicerole.py b/voicerole/voicerole.py deleted file mode 100644 index bfe5d836..00000000 --- a/voicerole/voicerole.py +++ /dev/null @@ -1,110 +0,0 @@ -from redbot.core import checks -from redbot.core import commands -from redbot.core.bot import Red -from redbot.core.utils.chat_formatting import * - -from rpadutils import CogSettings, get_role_from_id - - -class VoiceRole(commands.Cog): - """Gives a custom to anyone who enters a voice channel. - - THIS ROLE MUST EXIST AND THE BOT MUST HAVE THE RIGHTS TO CHANGE ROLES FOR IT TO WORK! - """ - - def __init__(self, bot: Red, *args, **kwargs): - super().__init__(*args, **kwargs) - self.bot = bot - self.settings = VoiceRoleSettings("voicerole") - - @commands.Cog.listener("on_voice_state_update") - async def _on_voice_state_update(self, member, before, after): - guild = member.guild - guild_id = guild.id - channel_id = (before.channel or after.channel).id - - channel_roles = self.settings.get_channel_roles(guild_id) - if channel_id not in channel_roles: - return - - role_id = channel_roles[channel_id] - try: - role = get_role_from_id(self.bot, guild, role_id) - if member.voice: - await member.add_roles(role) - else: - await member.remove_roles(role) - except Exception as ex: - print('voicerole failure {} {} {}'.format(guild_id, channel_id, role_id)) - print(ex) - - @commands.group() - @commands.guild_only() - @checks.mod_or_permissions(administrator=True) - async def voicerole(self, ctx): - """Automatic role adjustment on VC enter/exit.""" - pass - - @voicerole.command() - async def set(self, ctx, channel: discord.VoiceChannel, role: discord.Role): - """Associate a channel with a role. - - To reference a voice channel, use this syntax: - <#328254327321919489> - - Get the ID by enabling developer tools, right-clicking on the VC, and - selecting 'copy id'. - - To reference a role, make it pingable. - """ - self.settings.add_channel_role(ctx.guild.id, channel.id, role.id) - await ctx.send('done') - - @voicerole.command() - async def clear(self, ctx, channel: discord.VoiceChannel): - """Clear the role associated with a channel""" - self.settings.rm_channel_role(ctx.guild.id, channel.id) - await ctx.send('done') - - @voicerole.command() - async def list(self, ctx): - """List the channel/role associations for this server.""" - msg = 'Channel -> Role:' - for channel_id, role_id in self.settings.get_channel_roles(ctx.guild.id).items(): - msg += '\n\t{} : {}'.format(channel_id, role_id) - await ctx.send(box(msg)) - - -class VoiceRoleSettings(CogSettings): - def make_default_settings(self): - config = { - 'servers': {} - } - return config - - def guild_configs(self): - return self.bot_settings['servers'] - - def get_guild(self, guild_id): - configs = self.guild_configs() - if guild_id not in configs: - configs[guild_id] = {} - return configs[guild_id] - - def get_channel_roles(self, guild_id): - key = 'channel_ids' - guild = self.get_guild(guild_id) - if key not in guild: - guild[key] = {} - return guild[key] - - def add_channel_role(self, server_id, channel_id, role_id): - channel_roles = self.get_channel_roles(server_id) - channel_roles[channel_id] = role_id - self.save_settings() - - def rm_channel_role(self, guild_id, channel_id): - channel_roles = self.get_channel_roles(guild_id) - if channel_id in channel_roles: - channel_roles.pop(channel_id) - self.save_settings()