Merge remote-tracking branch 'upstream/master'

This commit is contained in:
Kevin Rode 2021-01-09 16:02:41 -05:00
commit c7c675fd07
7 changed files with 410 additions and 19 deletions

3
.gitignore vendored
View File

@ -347,5 +347,8 @@ ids
# database # database
matteo.db matteo.db
matteo.db-wal
matteo.db-shm
/matteo_env/Lib/site-packages/flask_socketio/__init__.py /matteo_env/Lib/site-packages/flask_socketio/__init__.py
*_bak *_bak
env

View File

@ -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: - use this command at the top of a list with entries separated by new lines:
- the away team's name. - the away team's name.
- the home 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. - 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 - m;randomgame
- starts a 9-inning game between 2 entirely random teams. embrace chaos! - starts a 9-inning game between 2 entirely random teams. embrace chaos!
- m;starttournament --rounddelay # - 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. - 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: - use this command at the top of a list with entries separated by new lines:
- the name of the tournament. - 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 - Chris Denmark
- Astrid Bek - Astrid Bek
- Kameleon - Kameleon
- Ryan Littleton
- Evie Diver

View File

@ -2,12 +2,17 @@
import os, json, datetime, re import os, json, datetime, re
import sqlite3 as sql import sqlite3 as sql
data_dir = "data"
def create_connection(): def create_connection():
#create connection, create db if doesn't exist #create connection, create db if doesn't exist
conn = None conn = None
try: 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 return conn
except: except:
print("oops, db connection no work") print("oops, db connection no work")
@ -191,9 +196,13 @@ def get_user_player_conn(conn, user):
except TypeError: except TypeError:
return False return False
else: else:
print(conn) conn.close()
return False
except: except:
print(conn) conn.close()
return False
conn.close()
return False
def get_user_player(user): def get_user_player(user):
conn = create_connection() conn = create_connection()
@ -216,6 +225,8 @@ def save_team(name, team_json_string, user_id):
return False return False
except: except:
return False return False
conn.close()
return False
def update_team(name, team_json_string): def update_team(name, team_json_string):
conn = create_connection() conn = create_connection()
@ -230,7 +241,10 @@ def update_team(name, team_json_string):
conn.close() conn.close()
return False return False
except: except:
conn.close()
return False return False
conn.close()
return False
def get_team(name, owner=False): def get_team(name, owner=False):
conn = create_connection() conn = create_connection()
@ -263,6 +277,8 @@ def delete_team(team):
except: except:
conn.close() conn.close()
return False return False
conn.close()
return False
def assign_owner(team_name, owner_id): def assign_owner(team_name, owner_id):
conn = create_connection() conn = create_connection()
@ -276,6 +292,8 @@ def assign_owner(team_name, owner_id):
except: except:
conn.close() conn.close()
return False return False
conn.close()
return False
def get_all_teams(): def get_all_teams():
conn = create_connection() conn = create_connection()

View File

@ -2,8 +2,13 @@ import json, random, os, math, jsonpickle
from enum import Enum from enum import Enum
import database as db import database as db
data_dir = "data"
games_config_file = os.path.join(data_dir, "games_config.json")
def config(): 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 #generate default config
config_dic = { config_dic = {
"default_length" : 3, "default_length" : 3,
@ -16,11 +21,11 @@ def config():
"stolen_base_chance_mod" : 1, "stolen_base_chance_mod" : 1,
"stolen_base_success_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) json.dump(config_dic, config_file, indent=4)
return config_dic return config_dic
else: else:
with open("games_config.json") as config_file: with open(games_config_file) as config_file:
return json.load(config_file) return json.load(config_file)
def all_weathers(): def all_weathers():
@ -66,7 +71,7 @@ class appearance_outcomes(Enum):
single = "hits a single!" single = "hits a single!"
double = "hits a double!" double = "hits a double!"
triple = "hits a triple!" triple = "hits a triple!"
homerun = "hits a home run!" homerun = "hits a dinger!"
grandslam = "hits a grand slam!" grandslam = "hits a grand slam!"
crows = "is chased away by crows." 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: 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 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 result["mulligan"] = True
return (result, 0) return (result, 0)

View File

@ -1,12 +1,29 @@
#interfaces with onomancer #interfaces with onomancer
import requests, json, urllib import requests, json, urllib
from requests.adapters import HTTPAdapter
from requests.packages.urllib3.util.retry import Retry
import database as db import database as db
onomancer_url = "https://onomancer.sibr.dev/api/" onomancer_url = "https://onomancer.sibr.dev/api/"
name_stats_hook = "getOrGenerateStats?name=" name_stats_hook = "getOrGenerateStats?name="
collection_hook = "getCollection?token=" 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): def get_stats(name):
player = db.get_stats(name) player = db.get_stats(name)
@ -14,7 +31,7 @@ def get_stats(name):
return player #returns json_string return player #returns json_string
#yell at onomancer if not in cache or too old #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: if response.status_code == 200:
stats = json.dumps(response.json()) stats = json.dumps(response.json())
db.cache_stats(name, stats) db.cache_stats(name, stats)
@ -30,9 +47,32 @@ def get_scream(username):
return scream return scream
def get_collection(collection_url): 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: if response.status_code == 200:
for player in response.json()['lineup'] + response.json()['rotation']: for player in response.json()['lineup'] + response.json()['rotation']:
db.cache_stats(player['name'], json.dumps(player)) db.cache_stats(player['name'], json.dumps(player))
return json.dumps(response.json()) 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

153
the_draft.py Normal file
View File

@ -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())

View File

@ -1,9 +1,13 @@
import discord, json, math, os, roman, games, asyncio, random, main_controller, threading, time, urllib, leagues import discord, json, math, os, roman, games, asyncio, random, main_controller, threading, time, urllib, leagues
import database as db import database as db
import onomancer as ono import onomancer as ono
import random
from the_draft import Draft, DRAFT_ROUNDS
from flask import Flask from flask import Flask
from uuid import uuid4 from uuid import uuid4
data_dir = "data"
config_filename = os.path.join(data_dir, "config.json")
class Command: class Command:
def isauthorized(self, user): def isauthorized(self, user):
@ -12,6 +16,12 @@ class Command:
async def execute(self, msg, command): async def execute(self, msg, command):
return return
class DraftError(Exception):
pass
class SlowDraftError(DraftError):
pass
class CommandError(Exception): class CommandError(Exception):
pass pass
@ -109,10 +119,11 @@ class ShowPlayerCommand(Command):
class StartGameCommand(Command): class StartGameCommand(Command):
name = "startgame" name = "startgame"
template = "m;startgame [away] [home] [innings]" 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 away team's name.
- the home 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): async def execute(self, msg, command):
league = None league = None
@ -473,7 +484,6 @@ class AssignOwnerCommand(Command):
async def execute(self, msg, command): async def execute(self, msg, command):
new_owner = msg.mentions[0] new_owner = msg.mentions[0]
team_name = command.strip().split(new_owner.mention+" ")[1] team_name = command.strip().split(new_owner.mention+" ")[1]
print(team_name)
if db.assign_owner(team_name, new_owner.id): 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.") await msg.channel.send(f"{team_name} is now owned by {new_owner.display_name}. Don't break it.")
else: else:
@ -492,7 +502,7 @@ class StartTournamentCommand(Command):
template = """m;starttournament template = """m;starttournament
[tournament name] [tournament name]
[list of teams, each on a new line]""" [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): async def execute(self, msg, command):
if config()["game_freeze"]: if config()["game_freeze"]:
@ -572,6 +582,152 @@ class StartTournamentCommand(Command):
await start_tournament_round(channel, tourney) 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 = [ commands = [
IntroduceCommand(), IntroduceCommand(),
CountActiveGamesCommand(), CountActiveGamesCommand(),
@ -596,6 +752,8 @@ commands = [
CreditCommand(), CreditCommand(),
RomanCommand(), RomanCommand(),
HelpCommand(), HelpCommand(),
StartDraftCommand(),
DraftPlayerCommand(),
ShowHistoryCommand(), ShowHistoryCommand(),
] ]
@ -608,7 +766,9 @@ thread1 = threading.Thread(target=main_controller.update_loop)
thread1.start() thread1.start()
def config(): 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 #generate default config
config_dic = { config_dic = {
"token" : "", "token" : "",
@ -620,12 +780,12 @@ def config():
"soulscream channel id" : 0, "soulscream channel id" : 0,
"game_freeze" : 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) 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!") print("please fill in bot token and any bot admin discord ids to the new config.json file!")
quit() quit()
else: else:
with open("config.json") as config_file: with open(config_filename) as config_file:
return json.load(config_file) return json.load(config_file)
@client.event @client.event
@ -1035,6 +1195,16 @@ async def team_delete_confirm(channel, team, owner):
return 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): def build_team_embed(team):
embed = discord.Embed(color=discord.Color.purple(), title=team.name) embed = discord.Embed(color=discord.Color.purple(), title=team.name)
lineup_string = "" lineup_string = ""