From e050b3ba77f70ae1088f62503296a63606bef628 Mon Sep 17 00:00:00 2001 From: Dan Hess Date: Fri, 26 Mar 2021 12:33:23 -0800 Subject: [PATCH] Move shiritori into a class as it was getting complicated --- cogs/games.py | 244 +++++++++++++++++++++++++++++++++------------ utils/utilities.py | 23 ----- 2 files changed, 179 insertions(+), 88 deletions(-) diff --git a/cogs/games.py b/cogs/games.py index 143d4ff..35bc761 100644 --- a/cogs/games.py +++ b/cogs/games.py @@ -1,9 +1,178 @@ +from dataclasses import dataclass from discord.ext import commands import collections import utils +@dataclass +class JPWord: + kanji: str + reading: str + + def __eq__(self, other): + return ( + isinstance(other, JPWord) + and self.kanji == other.kanji + and self.reading == other.reading + ) + + def __hash__(self): + return hash(self.kanji + self.reading) + + +class Shiritori: + def __init__(self): + self.losers = [] + self.players = [] + self.last_kana = None + self.last_player = None + self.used_words = [] + + # This won't include small tsu because for this use case + small_kanas = ["ゃ", "ゅ", "ょ", "ぁ", "ぃ", "ぅ", "ぇ", "ぉ"] + + def get_first_kana(self, word: JPWord): + i = iter(word.reading) + + for char in i: + # I don't know why someone would do it, but something like 「りんご」 + # I'd want to ignore the [] + if char.isalpha(): + # If there is a small kana, like りゅ then we want both + try: + n = next(i) + except StopIteration: + return char + else: + if n in self.small_kanas: + return f"{char}{n}" + else: + return char + + def get_last_kana(self, word: JPWord): + i = reversed(word.reading) + + for char in i: + # Ignore anything in case they end with something like ! or ? + if char.isalpha(): + # If the last one is a small kana (other than つ) + if char in self.small_kanas: + return f"{next(i)}{char}" + else: + return char + + def cull_words(self, words: list): + """Removes readings that will not fit based on last used kana""" + c = words.copy() + for word in words: + first = self.get_first_kana(word) + + if self.last_kana is not None and first != self.last_kana: + c.remove(word) + + return c + + async def player_lost(self, ctx: commands.Context, msg: str = None): + """Sends a player lost message, tracks players losing, and returns + based on whether or not the game is now over""" + self.losers.append(ctx.author) + self.players.remove(ctx.author) + + if msg: + await ctx.send(msg) + + return len(self.players) == 1 + + async def readings_for_word(self, word: str): + """This goes through the list that jisho API gives us and returns the right reading and word + This does no validation of kana, this is only for the proper kanji/reading pairs that the player + was possibly meaning to use""" + data = await utils.request( + "https://jisho.org/api/v1/search/words", payload={"keyword": word} + ) + + possibilities = set() + + for slug in data["data"]: + is_noun = False + # Determinte if this slug is a noun + for sense in slug["senses"]: + if "Noun" in sense["parts_of_speech"]: + is_noun = True + break + # If this isn't a noun we don't even care + if not is_noun: + continue + # Now go through definitions to get readings/kanji + for japanese in slug["japanese"]: + # They can enter either the kanji or the reading they want, compare both + _word = japanese.get("word") + reading = japanese.get("reading") + + # If there are readings of this word that have a paired kanji, prefer that + if _word is None and possibilities: + continue + elif _word is None: + possibilities.add(JPWord("", reading)) + + # If the provided kanji/reading is right, then add it as a possibility + if _word == word or reading == word: + possibilities.add(JPWord(_word, reading)) + + return list(possibilities) + + async def validate_word(self, ctx: commands.Context, word: str): + # First make sure the we ensure the player is tracked + if ctx.author in self.losers: + await ctx.message.add_reaction("❌") + return False + if ctx.author not in self.players: + self.players.append(ctx.author) + + # Get the words that could have been meant + words = await self.readings_for_word(word) + + # Validate that any words were returned + if not words: + msg = f"{ctx.author} loses, only Japanese nouns can be used" + # msg += f"\n{ctx.author}の勝利。日本語の名詞のみ使用できる" + return await self.player_lost(ctx, msg) + # Validate there are still words, if not then the last kana wasn't right + words = self.cull_words(words) + if self.last_kana is not None and not words: + msg = f'{ctx.author} loses, you must enter a word that begins with "{self.last_kana}"' + # msg += f"\n{ctx.author}の勝利。「{self.last_kana}」から始まる言葉を入力しなくてはなりません" + return await self.player_lost(ctx, msg) + # Validate it's more than one syllable + words = [word for word in words if len(word.reading) > 1] + if not words: + msg = f"{ctx.author} please only use words with more than one syllable" + # msg += f"\n{ctx.author}、複数の音節を持つ単語のみを使用してください" + await ctx.send(msg) + return False + # If we're here, we have one (or more, but we care about the first) matching word + # just base on that from now on + word = words[0] + # Validate against words used + if word in self.used_words: + msg = f"{ctx.author} loses, {word} has already been used" + # msg += f"\n{ctx.author}の勝利。「{word}」は既に使用されている" + return await self.player_lost(ctx, msg) + # Validate against ん and ン + last_kana = self.get_last_kana(word) + if last_kana in ("ん", "ン"): + msg = f"{ctx.author} loses, last letter cannot be ん" + # msg += f"\n{ctx.author}の勝利。最後の仮名を「ん」にすることはできない" + return await self.player_lost(ctx, msg) + # Append the first matched word since we have one + self.used_words.append(word) + self.last_kana = last_kana + self.last_player = ctx.author + await ctx.message.add_reaction("🟢") + return False + + class Games(commands.Cog): def __init__(self): self.running_games = collections.defaultdict(dict) @@ -20,73 +189,18 @@ class Games(commands.Cog): The kana ん cannot be used, as no word in Japanese starts with this The word used cannot be a previously given word """ - game = self.running_games["shiritori"].get(ctx.channel.id) # Ensure only one game is happening at once - if game is not None: - if ctx.author not in game["players"]: - game["players"].append(ctx.author) - else: - self.running_games["shiritori"][ctx.channel.id] = { - "players": [ctx.author], - "words_used": [], - "last_letters": [], - "last_author": ctx.author, - } - game = self.running_games["shiritori"].get(ctx.channel.id) + game = self.running_games["shiritori"].get(ctx.channel.id) + if game is None: + game = Shiritori() + self.running_games["shiritori"][ctx.channel.id] = game - def grab_letter(readings, last=True): - readings = [reversed(word) if last else iter(word) for word in readings] - - letters = [] - - for reading in readings: - for char in reading: - if char.isalpha(): - letters.append(char) - break - - return letters - - # Don't allow last author to guess again - if game["words_used"] and ctx.author == game["last_author"]: - return await ctx.send("You guessed last, someone else's turn") - is_noun, readings = await utils.readings_for_word(word) - # Only nouns can be used - if not is_noun: - self.running_games["shiritori"][ctx.channel.id] = None - return await ctx.send( - f"Game over! {ctx.author} loses, only nouns can be used" - ) - # Grab the first letter of this new word and check it - first_letters = grab_letter(readings, last=False) - last_letters = grab_letter(readings, last=False) - # Include extra check for if this is the first word - if game["words_used"]: - # Check if there's a match between first and last letters - if not any(char in first_letters for char in game["last_letters"]): - self.running_games["shiritori"][ctx.channel.id] = None - return await ctx.send( - f"Game over! {ctx.author} loses, first letter of {word} did not match last letter used" - ) - - # ん cannot be used - if any(char in last_letters for char in ("ん", "ン")): - self.running_games["shiritori"][ctx.channel.id] = None - return await ctx.send( - f"Game over! {ctx.author} loses, last letter cannot be ん" - ) - # Cannot reuuse words - if word in game["words_used"]: - self.running_games["shiritori"][ctx.channel.id] = None - return await ctx.send( - f"Game over! {ctx.author} loses, {word} has already been used" - ) - - # If we're here, then the last letter used was valid, save stuff - game["words_used"].append(word) - game["last_letters"] = last_letters - game["last_author"] = ctx.author - await ctx.message.add_reaction("✅") + if await game.validate_word(ctx, word): + winner = game.players[0] + msg = f"{winner} wins!" + # msg += f"\n{winner}の負け" + await ctx.send(msg) + del self.running_games["shiritori"][ctx.channel.id] def setup(bot): diff --git a/utils/utilities.py b/utils/utilities.py index 8de7af5..a782627 100644 --- a/utils/utilities.py +++ b/utils/utilities.py @@ -12,29 +12,6 @@ def channel_is_nsfw(channel): return isinstance(channel, discord.DMChannel) or channel.is_nsfw() -async def readings_for_word(word): - """Returns a tuple, the first element representing if this word is a noun or not, - the second being all the readings for this word""" - data = await request( - "https://jisho.org/api/v1/search/words", payload={"keyword": word} - ) - is_noun = False - readings = [] - - for piece in data["data"]: - # Jisho returns parts of words too, we don't want those for this use case - if piece["slug"] != word: - continue - - # Provide the readings for this word - readings.extend([r["reading"] for r in piece["japanese"] if r["word"] == word]) - for sense in piece["senses"]: - if "Noun" in sense["parts_of_speech"]: - is_noun = True - - return is_noun, readings - - async def download_image(url): """Returns a file-like object based on the URL provided""" # Simply read the image, to get the bytes