diff --git a/.gitignore b/.gitignore index 72644a6..6b7e8e2 100644 --- a/.gitignore +++ b/.gitignore @@ -350,3 +350,5 @@ matteo.db matteo.db-wal matteo.db-shm /matteo_env/Lib/site-packages/flask_socketio/__init__.py + +env diff --git a/games.py b/games.py index 8ca356d..b89d970 100644 --- a/games.py +++ b/games.py @@ -806,4 +806,4 @@ class weather(object): self.counter_home = 0 def __str__(self): - return f"{self.emoji} {self.name}" \ No newline at end of file + return f"{self.emoji} {self.name}" diff --git a/onomancer.py b/onomancer.py index f270a48..278d128 100644 --- a/onomancer.py +++ b/onomancer.py @@ -1,12 +1,29 @@ #interfaces with onomancer import requests, json, urllib +from requests.adapters import HTTPAdapter +from requests.packages.urllib3.util.retry import Retry import database as db onomancer_url = "https://onomancer.sibr.dev/api/" name_stats_hook = "getOrGenerateStats?name=" collection_hook = "getCollection?token=" +names_hook = "getNames" + + +def _retry_session(retries=3, backoff=0.3, status=(500, 501, 502, 503, 504)): + session = requests.Session() + retry = Retry( + total=retries, + read=retries, + connect=retries, + backoff_factor=backoff, + status_forcelist=status, + ) + adapter = HTTPAdapter(max_retries=retry) + session.mount('https://', adapter) + return session def get_stats(name): player = db.get_stats(name) @@ -14,7 +31,7 @@ def get_stats(name): return player #returns json_string #yell at onomancer if not in cache or too old - response = requests.get(onomancer_url + name_stats_hook + urllib.parse.quote_plus(name)) + response = _retry_session().get(onomancer_url + name_stats_hook + urllib.parse.quote_plus(name)) if response.status_code == 200: stats = json.dumps(response.json()) db.cache_stats(name, stats) @@ -30,9 +47,32 @@ def get_scream(username): return scream def get_collection(collection_url): - response = requests.get(onomancer_url + collection_hook + urllib.parse.quote(collection_url)) + response = _retry_session().get(onomancer_url + collection_hook + urllib.parse.quote(collection_url)) if response.status_code == 200: for player in response.json()['lineup'] + response.json()['rotation']: db.cache_stats(player['name'], json.dumps(player)) return json.dumps(response.json()) + + +def get_names(limit=20, threshold=1): + """ + Get `limit` random players that have at least `threshold` upvotes. + Returns dictionary keyed by player name of stats. + """ + response = _retry_session().get( + onomancer_url + names_hook, + params={ + 'limit': limit, + 'threshold': threshold, + 'with_stats': 1, + 'random': 1, + }, + ) + response.raise_for_status() + res = {} + for stats in response.json(): + name = stats['name'] + db.cache_stats(name, json.dumps(stats)) + res[name] = stats + return res diff --git a/the_draft.py b/the_draft.py new file mode 100644 index 0000000..cfb4cbc --- /dev/null +++ b/the_draft.py @@ -0,0 +1,153 @@ +from collections import namedtuple +import games +import json +import uuid + +import onomancer + +DRAFT_SIZE = 20 +REFRESH_DRAFT_SIZE = 4 # fewer players remaining than this and the list refreshes +DRAFT_ROUNDS = 13 + +Participant = namedtuple('Participant', ['handle', 'team']) +BOOKMARK = Participant(handle="bookmark", team=None) # keep track of start/end of draft round + + +class Draft: + """ + Represents a draft party with n participants constructing their team from a pool + of names. + """ + + @classmethod + def make_draft(cls): + draft = cls() + return draft + + def __init__(self): + self._id = str(uuid.uuid4())[:6] + self._participants = [] + self._active_participant = BOOKMARK # draft mutex + self._players = onomancer.get_names(limit=DRAFT_SIZE) + self._round = 0 + + @property + def round(self): + """ + Current draft round. 1 indexed. + """ + return self._round + + @property + def active_drafter(self): + """ + Handle of whomever is currently up to draft. + """ + return self._active_participant.handle + + @property + def active_drafting_team(self): + return self._active_participant.team.name + + def add_participant(self, handle, team_name, slogan): + """ + A participant is someone participating in this draft. Initializes an empty team for them + in memory. + + `handle`: discord @ handle, for ownership and identification + """ + team = games.team() + team.name = team_name + team.slogan = slogan + self._participants.append(Participant(handle=handle, team=team)) + + def start_draft(self): + """ + Call after adding all participants and confirming they're good to go. + """ + self.advance_draft() + + def refresh_players(self): + self._players = onomancer.get_names(limit=DRAFT_SIZE) + + def advance_draft(self): + """ + The participant list is treated as a circular queue with the head being popped off + to act as the draftign mutex. + """ + if self._active_participant == BOOKMARK: + self._round += 1 + self._participants.append(self._active_participant) + self._active_participant = self._participants.pop(0) + + def get_draftees(self): + return list(self._players.keys()) + + def draft_player(self, handle, player_name): + """ + `handle` is the participant's discord handle. + """ + if self._active_participant.handle != handle: + raise ValueError(f'{self._active_participant.handle} is drafting, not you') + + player_name = player_name.strip() + + player = self._players.get(player_name) + if not player: + # might be some whitespace shenanigans + for name, stats in self._players.items(): + if name.replace('\xa0', ' ').strip().lower() == player_name.lower(): + player = stats + break + else: + # still not found + raise ValueError(f'Player `{player_name}` not in draft list') + del self._players[player['name']] + + if len(self._players) <= REFRESH_DRAFT_SIZE: + self.refresh_players() + + if self._round < DRAFT_ROUNDS: + self._active_participant.team.add_lineup(games.player(json.dumps(player))) + elif self._round == DRAFT_ROUNDS: + self._active_participant.team.add_pitcher(games.player(json.dumps(player))) + + self.advance_draft() + if self._active_participant == BOOKMARK: + self.advance_draft() + + return player + + def get_teams(self): + teams = [] + if self._active_participant != BOOKMARK: + teams.append((self._active_participant.handle, self._active_participant.team)) + for participant in self._participants: + if participant != BOOKMARK: + teams.append((participant.handle, participant.team)) + return teams + + def finish_draft(self): + for handle, team in self.get_teams(): + success = games.save_team(team, int(handle[3:-1])) + if not success: + raise Exception(f'Error saving team for {handle}') + + +if __name__ == '__main__': + # extremely robust testing OC do not steal + DRAFT_ROUNDS = 2 + draft = Draft.make_draft() + draft.add_participant('@bluh', 'Bluhstein Bluhs', 'bluh bluh bluh') + draft.add_participant('@what', 'Barcelona IDK', 'huh') + draft.start_draft() + + while draft.round <= DRAFT_ROUNDS: + print(draft.get_draftees()) + cmd = input(f'{draft.round} {draft.active_drafter}:') + drafter, player = cmd.split(' ', 1) + try: + draft.draft_player(drafter, player) + except ValueError as e: + print(e) + print(draft.get_teams()) diff --git a/the_prestige.py b/the_prestige.py index 87a1959..fa0206d 100644 --- a/the_prestige.py +++ b/the_prestige.py @@ -1,6 +1,8 @@ import discord, json, math, os, roman, games, asyncio, random, main_controller, threading, time, urllib, leagues import database as db import onomancer as ono +import random +from the_draft import Draft, DRAFT_ROUNDS from flask import Flask from uuid import uuid4 @@ -14,6 +16,12 @@ class Command: async def execute(self, msg, command): return +class DraftError(Exception): + pass + +class SlowDraftError(DraftError): + pass + class CommandError(Exception): pass @@ -572,6 +580,152 @@ class StartTournamentCommand(Command): await start_tournament_round(channel, tourney) +class DraftPlayerCommand(Command): + name = "draft" + template = "m;draft [playername]" + description = "On your turn during a draft, use this command to pick your player." + + async def execute(self, msg, command): + """ + This is a no-op definition. `StartDraftCommand` handles the orchestration directly, + this is just here to provide a help entry and so the command dispatcher recognizes it + as valid. + """ + pass + + +class StartDraftCommand(Command): + name = "startdraft" + template = "m;startdraft [mention] [teamname] [slogan]" + description = """Starts a draft with an arbitrary number of participants. Send this command at the top of the list with each mention, teamname, and slogan on a new line (shift+enter in discord). + - The draft will proceed in the order that participants were entered. + - 20 players will be available for draft at a time, and the pool will refresh automatically when it becomes small. + - Each participant will be asked to draft 12 hitters then finally one pitcher. + - The draft will start only once every participant has given a πŸ‘ to begin. + - use the command `d`, `draft`, or `m;draft` on your turn to draft someone + """ + + async def execute(self, msg, command): + draft = Draft.make_draft() + mentions = {f'<@!{m.id}>' for m in msg.mentions} + content = msg.content.split('\n')[1:] # drop command out of message + if len(content) % 3: + await msg.channel.send('Invalid list') + raise ValueError('Invalid length') + + for i in range(0, len(content), 3): + handle_token = content[i].strip() + for mention in mentions: + if mention in handle_token: + handle = mention + break + else: + await msg.channel.send(f"I don't recognize {handle_token}") + return + team_name = content[i + 1].strip() + if games.get_team(team_name): + await msg.channel.send(f'Sorry {handle}, {team_name} already exists') + return + slogan = content[i + 2].strip() + draft.add_participant(handle, team_name, slogan) + + success = await self.wait_start(msg.channel, mentions) + if not success: + return + + draft.start_draft() + footer = f"The draft class of {random.randint(2007, 2075)}" + while draft.round <= DRAFT_ROUNDS: + message_prefix = f'Round {draft.round}/{DRAFT_ROUNDS}:' + if draft.round == DRAFT_ROUNDS: + body = random.choice([ + f"Now just choose a pitcher and we can finish off this paperwork for you, {draft.active_drafter}", + f"Pick a pitcher, {draft.active_drafter}, and we can all go home happy. 'Cept your players. They'll have to play bllaseball.", + f"Almost done, {draft.active_drafter}. Pick your pitcher.", + ]) + message = f"⚾️ {message_prefix} {body}" + else: + body = random.choice([ + f"Choose a batter, {draft.active_drafter}", + f"{draft.active_drafter}, your turn. Pick one.", + f"Pick one to fill your next lineup slot, {draft.active_drafter}", + f"Alright, {draft.active_drafter}, choose a batter.", + ]) + message = f"🏏 {message_prefix} {body}" + await msg.channel.send( + message, + embed=build_draft_embed(draft.get_draftees(), footer=footer), + ) + try: + draft_message = await self.wait_draft(msg.channel, draft) + draft.draft_player(f'<@!{draft_message.author.id}>', draft_message.content.split(' ', 1)[1]) + except SlowDraftError: + player = random.choice(draft.get_draftees()) + await msg.channel.send(f"I'm not waiting forever. You get {player}. Next.") + draft.draft_player(draft.active_drafter, player) + except ValueError as e: + await msg.channel.send(str(e)) + except IndexError: + await msg.channel.send("Quit the funny business.") + + for handle, team in draft.get_teams(): + await msg.channel.send( + random.choice([ + f"Done and dusted, {handle}. Here's your squad.", + f"Behold the {team.name}, {handle}. Flawless, we think.", + f"Oh, huh. Interesting stat distribution. Good luck, {handle}.", + ]), + embed=build_team_embed(team), + ) + try: + draft.finish_draft() + except Exception as e: + await msg.channel.send(str(e)) + + async def wait_start(self, channel, mentions): + start_msg = await channel.send("Sound off, folks. πŸ‘ if you're good to go " + " ".join(mentions)) + await start_msg.add_reaction("πŸ‘") + await start_msg.add_reaction("πŸ‘Ž") + + def react_check(react, user): + return f'<@!{user.id}>' in mentions and react.message == start_msg + + while True: + try: + react, _ = await client.wait_for('reaction_add', timeout=60.0, check=react_check) + if react.emoji == "πŸ‘Ž": + await channel.send("We dragged out the photocopier for this! Fine, putting it back.") + return False + if react.emoji == "πŸ‘": + reactors = set() + async for user in react.users(): + reactors.add(f'<@!{user.id}>') + if reactors.intersection(mentions) == mentions: + return True + except asyncio.TimeoutError: + await channel.send("Y'all aren't ready.") + return False + return False + + async def wait_draft(self, channel, draft): + + def check(m): + if m.channel != channel: + return False + if m.content.startswith('d ') or m.content.startswith('draft '): + return True + for prefix in config()['prefix']: + if m.content.startswith(prefix + 'draft '): + return True + return False + + try: + draft_message = await client.wait_for('message', timeout=120.0, check=check) + except asyncio.TimeoutError: + raise SlowDraftError('Too slow') + return draft_message + + commands = [ IntroduceCommand(), CountActiveGamesCommand(), @@ -596,6 +750,8 @@ commands = [ CreditCommand(), RomanCommand(), HelpCommand(), + StartDraftCommand(), + DraftPlayerCommand(), ] client = discord.Client() @@ -1015,6 +1171,16 @@ async def team_delete_confirm(channel, team, owner): return +def build_draft_embed(names, title="The Draft", footer="You must choose"): + embed = discord.Embed(color=discord.Color.purple(), title=title) + column_size = 7 + for i in range(0, len(names), column_size): + draft = '\n'.join(names[i:i + column_size]) + embed.add_field(name="-", value=draft, inline=True) + embed.set_footer(text=footer) + return embed + + def build_team_embed(team): embed = discord.Embed(color=discord.Color.purple(), title=team.name) lineup_string = ""