diff --git a/.gitignore b/.gitignore index 18c6fc3..be02214 100644 --- a/.gitignore +++ b/.gitignore @@ -347,5 +347,8 @@ ids # database matteo.db +matteo.db-wal +matteo.db-shm /matteo_env/Lib/site-packages/flask_socketio/__init__.py *_bak +env diff --git a/README.md b/README.md index 65517ef..538d103 100644 --- a/README.md +++ b/README.md @@ -81,12 +81,12 @@ accepting pull requests, check the issues for to-dos. - use this command at the top of a list with entries separated by new lines: - the away team's name. - the home team's name. - - optionally, the number of innings, which must be greater than 2 and less than 31. if not included it will default to 9. + - optionally, the number of innings, which must be greater than 2 and less than 201. if not included it will default to 9. - this command has fuzzy search so you don't need to type the full name of the team as long as you give enough to identify the team you're looking for. - m;randomgame - starts a 9-inning game between 2 entirely random teams. embrace chaos! - m;starttournament --rounddelay # - - starts a randomly seeded tournament with up to 64 provided teams, automatically adding byes as necessary. all series have a 5 minute break between games. the current format is: best of 5 until the finals which are best of 7. + - starts a randomly seeded tournament with the provided teams, automatically adding byes as necessary. all series have a 5 minute break between games. the current format is: best of 5 until the finals which are best of 7. - the --rounddelay is optional, if used, # must be between 1 and 120 and it'll set the delay between rounds to be # minutes. if not included it will default to 10. - use this command at the top of a list with entries separated by new lines: - the name of the tournament. @@ -107,3 +107,5 @@ these folks are helping me a *ton* via patreon, and i cannot possibly thank them - Chris Denmark - Astrid Bek - Kameleon +- Ryan Littleton +- Evie Diver diff --git a/database.py b/database.py index 2e40814..22b21c4 100644 --- a/database.py +++ b/database.py @@ -2,12 +2,17 @@ import os, json, datetime, re import sqlite3 as sql +data_dir = "data" def create_connection(): #create connection, create db if doesn't exist conn = None try: - conn = sql.connect("matteo.db") + conn = sql.connect(os.path.join(data_dir, "matteo.db")) + + # enable write-ahead log for performance and resilience + conn.execute('pragma journal_mode=wal') + return conn except: print("oops, db connection no work") @@ -191,9 +196,13 @@ def get_user_player_conn(conn, user): except TypeError: return False else: - print(conn) + conn.close() + return False except: - print(conn) + conn.close() + return False + conn.close() + return False def get_user_player(user): conn = create_connection() @@ -216,6 +225,8 @@ def save_team(name, team_json_string, user_id): return False except: return False + conn.close() + return False def update_team(name, team_json_string): conn = create_connection() @@ -230,7 +241,10 @@ def update_team(name, team_json_string): conn.close() return False except: + conn.close() return False + conn.close() + return False def get_team(name, owner=False): conn = create_connection() @@ -263,6 +277,8 @@ def delete_team(team): except: conn.close() return False + conn.close() + return False def assign_owner(team_name, owner_id): conn = create_connection() @@ -276,6 +292,8 @@ def assign_owner(team_name, owner_id): except: conn.close() return False + conn.close() + return False def get_all_teams(): conn = create_connection() diff --git a/games.py b/games.py index 2a684dc..980e614 100644 --- a/games.py +++ b/games.py @@ -2,8 +2,13 @@ import json, random, os, math, jsonpickle from enum import Enum import database as db +data_dir = "data" +games_config_file = os.path.join(data_dir, "games_config.json") + def config(): - if not os.path.exists("games_config.json"): + if not os.path.exists(os.path.dirname(games_config_file)): + os.makedirs(os.path.dirname(games_config_file)) + if not os.path.exists(games_config_file): #generate default config config_dic = { "default_length" : 3, @@ -16,11 +21,11 @@ def config(): "stolen_base_chance_mod" : 1, "stolen_base_success_mod" : 1 } - with open("games_config.json", "w") as config_file: + with open(games_config_file, "w") as config_file: json.dump(config_dic, config_file, indent=4) return config_dic else: - with open("games_config.json") as config_file: + with open(games_config_file) as config_file: return json.load(config_file) def all_weathers(): @@ -66,7 +71,7 @@ class appearance_outcomes(Enum): single = "hits a single!" double = "hits a double!" triple = "hits a triple!" - homerun = "hits a home run!" + homerun = "hits a dinger!" grandslam = "hits a grand slam!" crows = "is chased away by crows." @@ -591,7 +596,7 @@ class game(object): if self.weather.name == "Slight Tailwind" and "mulligan" not in self.last_update[0].keys() and not result["ishit"] and result["text"] != appearance_outcomes.walk: mulligan_roll_target = -((((self.get_batter().stlats["batting_stars"])-5)/6)**2)+1 - if random.random() > mulligan_roll_target and self.get_batter().stlats["batting_stars"] >= 5: + if random.random() > mulligan_roll_target and self.get_batter().stlats["batting_stars"] <= 5: result["mulligan"] = True return (result, 0) 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 b90e7ce..c6665c5 100644 --- a/the_prestige.py +++ b/the_prestige.py @@ -1,9 +1,13 @@ 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 +data_dir = "data" +config_filename = os.path.join(data_dir, "config.json") class Command: def isauthorized(self, user): @@ -12,6 +16,12 @@ class Command: async def execute(self, msg, command): return +class DraftError(Exception): + pass + +class SlowDraftError(DraftError): + pass + class CommandError(Exception): pass @@ -109,10 +119,11 @@ class ShowPlayerCommand(Command): class StartGameCommand(Command): name = "startgame" template = "m;startgame [away] [home] [innings]" - description ="""Starts a game with premade teams made using saveteam, use this command at the top of a list followed by each of these in a new line (shift+enter in discord, or copy+paste from notepad) (this command has fuzzy search so you don't need to type the full name of the team as long as you give enough to identify the team you're looking for.): + description ="""Starts a game with premade teams made using saveteam, use this command at the top of a list followed by each of these in a new line (shift+enter in discord, or copy+paste from notepad): - the away team's name. - the home team's name. - - and finally, optionally, the number of innings, which must be greater than 2 and less than 31. if not included it will default to 9.""" + - and finally, optionally, the number of innings, which must be greater than 2 and less than 201. if not included it will default to 9. + - this command has fuzzy search so you don't need to type the full name of the team as long as you give enough to identify the team you're looking for.""" async def execute(self, msg, command): league = None @@ -473,7 +484,6 @@ class AssignOwnerCommand(Command): async def execute(self, msg, command): new_owner = msg.mentions[0] team_name = command.strip().split(new_owner.mention+" ")[1] - print(team_name) if db.assign_owner(team_name, new_owner.id): await msg.channel.send(f"{team_name} is now owned by {new_owner.display_name}. Don't break it.") else: @@ -492,7 +502,7 @@ class StartTournamentCommand(Command): template = """m;starttournament [tournament name] [list of teams, each on a new line]""" - description = "Starts a randomly seeded tournament with up to 64 provided teams, automatically adding byes as necessary. All series have a 5 minute break between games and by default there is a 10 minute break between rounds. The current tournament format is:\nBest of 5 until the finals, which are Best of 7." + description = "Starts a randomly seeded tournament with the provided teams, automatically adding byes as necessary. All series have a 5 minute break between games and by default there is a 10 minute break between rounds. The current tournament format is:\nBest of 5 until the finals, which are Best of 7." async def execute(self, msg, command): if config()["game_freeze"]: @@ -572,6 +582,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 +752,8 @@ commands = [ CreditCommand(), RomanCommand(), HelpCommand(), + StartDraftCommand(), + DraftPlayerCommand(), ShowHistoryCommand(), ] @@ -608,7 +766,9 @@ thread1 = threading.Thread(target=main_controller.update_loop) thread1.start() def config(): - if not os.path.exists("config.json"): + if not os.path.exists(os.path.dirname(config_filename)): + os.makedirs(os.path.dirname(config_filename)) + if not os.path.exists(config_filename): #generate default config config_dic = { "token" : "", @@ -620,12 +780,12 @@ def config(): "soulscream channel id" : 0, "game_freeze" : 0 } - with open("config.json", "w") as config_file: + with open(config_filename, "w") as config_file: json.dump(config_dic, config_file, indent=4) print("please fill in bot token and any bot admin discord ids to the new config.json file!") quit() else: - with open("config.json") as config_file: + with open(config_filename) as config_file: return json.load(config_file) @client.event @@ -1035,6 +1195,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 = ""