diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5c484d3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +venv/ +matteo_env/ +__pycache__/ +simmadome/node_modules +data/ +.git/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index b4a7181..af99f24 100644 --- a/.gitignore +++ b/.gitignore @@ -344,6 +344,7 @@ config.json games_config.json weather_config.json ids +data/ # database matteo.db @@ -351,5 +352,6 @@ matteo.db-wal matteo.db-shm /data/leagues/* /matteo_env/Lib/site-packages/flask_socketio/__init__.py +Pipfile env diff --git a/Dockerfile b/Dockerfile index 75d8a22..fc250eb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,20 @@ -FROM python:3.8 -EXPOSE 5000 - +# - Build stage 1: frontend (simmadome/ directory) +FROM node:alpine AS frontend WORKDIR /app -COPY . ./ +COPY simmadome/package.json simmadome/package-lock.json ./ +RUN npm install +COPY simmadome/ ./ +RUN npm run build + +# - Build stage 2: backend (Python) +FROM python:3.8 +EXPOSE 5000 +WORKDIR /app + +COPY requirements.txt . RUN pip install -r requirements.txt +COPY . ./ +COPY --from=frontend /app/build/ simmadome/build/ CMD ["python", "the_prestige.py"] diff --git a/games.py b/games.py index 6b2c99c..5a314c3 100644 --- a/games.py +++ b/games.py @@ -31,13 +31,14 @@ def config(): def all_weathers(): weathers_dic = { #"Supernova" : weather("Supernova", "🌟"), - "Midnight": weather("Midnight", "🕶"), + #"Midnight": weather("Midnight", "🕶"), "Slight Tailwind": weather("Slight Tailwind", "🏌️‍♀️"), - "Heavy Snow": weather("Heavy Snow", "❄") + "Heavy Snow": weather("Heavy Snow", "❄"), + "Twilight" : weather("Twilight", "👻"), + "Thinned Veil" : weather("Thinned Veil", "🌌") } return weathers_dic - class appearance_outcomes(Enum): strikeoutlooking = "strikes out looking." strikeoutswinging = "strikes out swinging." @@ -173,7 +174,7 @@ class team(object): if rotation_slot is None: self.pitcher = random.choice(temp_rotation) else: - self.pitcher = temp_rotation[rotation_slot % len(temp_rotation)] + self.pitcher = temp_rotation[(rotation_slot-1) % len(temp_rotation)] def is_ready(self): try: @@ -269,6 +270,13 @@ class game(object): pb_system_stat = (random.gauss(1*math.erf((bat_stat - pitch_stat)*1.5)-1.8,2.2)) hitnum = random.gauss(2*math.erf(bat_stat/4)-1,3) + if self.weather.name == "Twilight": + error_line = - (math.log(defender.stlats["defense_stars"] + 1)/50) + 1 + error_roll = random.random() + if error_roll > error_line: + outcome["error"] = True + outcome["defender"] = defender + pb_system_stat = 0.1 if pb_system_stat <= 0: @@ -313,7 +321,7 @@ class game(object): outcome["ishit"] = True if hitnum < 1: outcome["text"] = appearance_outcomes.single - elif hitnum < 2.85: + elif hitnum < 2.85 or "error" in outcome.keys(): outcome["text"] = appearance_outcomes.double elif hitnum < 3.1: outcome["text"] = appearance_outcomes.triple @@ -384,6 +392,11 @@ class game(object): if base is not None: runs += 1 self.bases = {1 : None, 2 : None, 3 : None} + if "veil" in outcome.keys(): + if runs < 4: + self.bases[runs] = self.get_batter() + else: + runs += 1 return runs elif "advance" in outcome.keys(): @@ -535,6 +548,10 @@ class game(object): elif result["text"] == appearance_outcomes.homerun or result["text"] == appearance_outcomes.grandslam: self.get_batter().game_stats["total_bases"] += 4 self.get_batter().game_stats["home_runs"] += 1 + if self.weather.name == "Thinned Veil": + result["veil"] = True + + scores_to_add += self.baserunner_check(defender, result) diff --git a/league_storage.py b/league_storage.py index 902b372..b3edb4c 100644 --- a/league_storage.py +++ b/league_storage.py @@ -64,6 +64,7 @@ def init_league_db(league): c.execute(teams_table_check_string) for team in league.teams_in_league(): + print(team) c.execute("INSERT INTO teams (name) VALUES (?)", (team.name,)) player_string = "INSERT INTO stats (name, team_name) VALUES (?,?)" @@ -72,22 +73,26 @@ def init_league_db(league): for pitcher in team.rotation: c.execute(player_string, (pitcher.name, team.name)) + conn.commit() + conn.close() + +def save_league(league): + if league_exists(league.name): state_dic = { + "season" : league.season, "day" : league.day, + "constraints" : league.constraints, "schedule" : league.schedule, "game_length" : league.game_length, "series_length" : league.series_length, "games_per_hour" : league.games_per_hour, - "historic" : False + "owner" : league.owner, + "champions" : league.champions, + "historic" : league.historic } - if not os.path.exists(os.path.dirname(os.path.join(data_dir, league_dir, league.name, f"{league.name}.state"))): - os.makedirs(os.path.dirname(os.path.join(data_dir, league_dir, league.name, f"{league.name}.state"))) with open(os.path.join(data_dir, league_dir, league.name, f"{league.name}.state"), "w") as state_file: json.dump(state_dic, state_file, indent=4) - conn.commit() - conn.close() - def add_stats(league_name, player_game_stats_list): conn = create_connection(league_name) if conn is not None: @@ -122,11 +127,22 @@ def update_standings(league_name, update_dic): conn.commit() conn.close() +def get_standings(league_name): + if league_exists(league_name): + conn = create_connection(league_name) + if conn is not None: + c = conn.cursor() + + c.execute("SELECT name, wins, losses, run_diff FROM teams",) + standings_array = c.fetchall() + conn.close() + return standings_array + def league_exists(league_name): with os.scandir(os.path.join(data_dir, league_dir)) as folder: for subfolder in folder: if league_name in subfolder.name: - return not state(league_name)["historic"] + return True return False \ No newline at end of file diff --git a/leagues.py b/leagues.py index 44dcf27..20c8b07 100644 --- a/leagues.py +++ b/leagues.py @@ -10,13 +10,20 @@ league_dir = "leagues" class league_structure(object): def __init__(self, name): self.name = name + self.historic = False + self.owner = None + self.season = 1 + self.autoplay = -1 + self.champions = {} def setup(self, league_dic, division_games = 1, inter_division_games = 1, inter_league_games = 1, games_per_hour = 2): - self.league = league_dic #key: subleague, value: {division : team_name} + self.league = league_dic # { subleague name : { division name : [team object] } } self.constraints = { "division_games" : division_games, "inter_div_games" : inter_division_games, - "inter_league_games" : inter_league_games + "inter_league_games" : inter_league_games, + "division_leaders" : 0, + "wild_cards" : 0 } self.day = 1 self.schedule = {} @@ -33,11 +40,46 @@ class league_structure(object): def last_series_check(self): - return str(math.ceil((self.day)/self.series_length) + 1) in self.schedule.keys() + return str(math.ceil((self.day)/self.series_length) + 1) not in self.schedule.keys() def day_to_series_num(self, day): return math.ceil((self.day)/self.series_length) + def tiebreaker_required(self): + standings = {} + matchups = [] + tournaments = [] + for team_name, wins, losses, run_diff in league_db.get_standings(self.name): + standings[team_name] = {"wins" : wins, "losses" : losses, "run_diff" : run_diff} + + for subleague in iter(self.league.keys()): + team_dic = {} + subleague_array = [] + wildcard_leaders = [] + for division in iter(self.league[subleague].keys()): + division_standings = [] + division_standings += self.division_standings(self.league[subleague][division], standings) + division_leaders = division_standings[:self.constraints["division_leaders"]] + for division_team, wins, losses, diff, gb in division_standings[self.constraints["division_leaders"]:]: + if division_team.name != division_leaders[-1][0].name and standings[division_team.name]["wins"] == standings[division_leaders[-1][0].name]["wins"]: + matchups.append((division_team, division_standings[self.constraints["division_leaders"]-1][0], f"{division} Tiebreaker")) + + this_div_wildcard = [this_team for this_team, wins, losses, diff, gb in self.division_standings(self.league[subleague][division], standings)[self.constraints["division_leaders"]:]] + subleague_array += this_div_wildcard + if self.constraints["wild_cards"] > 0: + wildcard_standings = self.division_standings(subleague_array, standings) + wildcard_leaders = wildcard_standings[:self.constraints["wild_cards"]] + for wildcard_team, wins, losses, diff, gb in wildcard_standings[self.constraints["wild_cards"]:]: + if wildcard_team.name != wildcard_leaders[-1][0].name and standings[wildcard_team.name]["wins"] == standings[wildcard_leaders[-1][0].name]["wins"]: + matchups.append((wildcard_team, wildcard_standings[self.constraints["wild_cards"]-1][0], f"{subleague} Wildcard Tiebreaker")) + + for team_a, team_b, type in matchups: + tourney = tournament(f"{self.name} {type}",{team_a : {"wins" : 1}, team_b : {"wins" : 0}}, finals_series_length=1, secs_between_games=int(3600/self.games_per_hour), secs_between_rounds=int(7200/self.games_per_hour)) + tourney.build_bracket(by_wins = True) + tourney.league = self + tournaments.append(tourney) + return tournaments + def find_team(self, team_name): for subleague in iter(self.league.keys()): for division in iter(self.league[subleague].keys()): @@ -164,19 +206,126 @@ class league_structure(object): day = 1 while not scheduled: found = False - if day in self.schedule.keys(): - for game_on_day in self.schedule[day]: + if str(day) in self.schedule.keys(): + for game_on_day in self.schedule[str(day)]: for team in game: if team in game_on_day: found = True if not found: - self.schedule[day].append(game) + self.schedule[str(day)].append(game) scheduled = True else: - self.schedule[day] = [game] + self.schedule[str(day)] = [game] scheduled = True day += 1 + def division_standings(self, division, standings): + def sorter(team_in_list): + if team_in_list[2] == 0 and team_in_list[1] == 0: + return (0, team_in_list[3]) + return (team_in_list[1]/(team_in_list[1]+team_in_list[2]), team_in_list[3]) + + teams = division.copy() + + for index in range(0, len(teams)): + this_team = teams[index] + teams[index] = [this_team, standings[teams[index].name]["wins"], standings[teams[index].name]["losses"], standings[teams[index].name]["run_diff"], 0] + + teams.sort(key=sorter, reverse=True) + return teams + + def season_length(self): + return int(list(self.schedule.keys())[-1]) * self.series_length + + def standings_embed(self): + this_embed = Embed(color=Color.purple(), title=self.name) + standings = {} + for team_name, wins, losses, run_diff in league_db.get_standings(self.name): + standings[team_name] = {"wins" : wins, "losses" : losses, "run_diff" : run_diff} + for subleague in iter(self.league.keys()): + this_embed.add_field(name="Subleague:", value=f"**{subleague}**", inline = False) + for division in iter(self.league[subleague].keys()): + teams = self.division_standings(self.league[subleague][division], standings) + + for index in range(0, len(teams)): + if index == self.constraints["division_leaders"] - 1: + teams[index][4] = "-" + else: + games_behind = ((teams[self.constraints["division_leaders"] - 1][1] - teams[index][1]) + (teams[index][2] - teams[self.constraints["division_leaders"] - 1][2]))/2 + teams[index][4] = games_behind + teams_string = "" + for this_team in teams: + if this_team[2] != 0 or this_team[1] != 0: + teams_string += f"**{this_team[0].name}\n**{this_team[1]} - {this_team[2]} WR: {round(this_team[1]/(this_team[1]+this_team[2]), 3)} GB: {this_team[4]}\n\n" + else: + teams_string += f"**{this_team[0].name}\n**{this_team[1]} - {this_team[2]} WR: - GB: {this_team[4]}\n\n" + + this_embed.add_field(name=f"{division} Division:", value=teams_string, inline = False) + + this_embed.set_footer(text=f"Standings as of day {self.day-1} / {self.season_length()}") + return this_embed + + def wildcard_embed(self): + this_embed = Embed(color=Color.purple(), title=f"{self.name} Wildcard Race") + standings = {} + for team_name, wins, losses, run_diff in league_db.get_standings(self.name): + standings[team_name] = {"wins" : wins, "losses" : losses, "run_diff" : run_diff} + for subleague in iter(self.league.keys()): + subleague_array = [] + for division in iter(self.league[subleague].keys()): + this_div = [this_team for this_team, wins, losses, diff, gb in self.division_standings(self.league[subleague][division], standings)[self.constraints["division_leaders"]:]] + subleague_array += this_div + + teams = self.division_standings(subleague_array, standings) + teams_string = "" + for index in range(0, len(teams)): + if index == self.constraints["wild_cards"] - 1: + teams[index][4] = "-" + else: + games_behind = ((teams[self.constraints["wild_cards"] - 1][1] - teams[index][1]) + (teams[index][2] - teams[self.constraints["wild_cards"] - 1][2]))/2 + teams[index][4] = games_behind + + for this_team in teams: + if this_team[2] != 0 or this_team[1] != 0: + teams_string += f"**{this_team[0].name}\n**{this_team[1]} - {this_team[2]} WR: {round(this_team[1]/(this_team[1]+this_team[2]), 3)} GB: {this_team[4]}\n\n" + else: + teams_string += f"**{this_team[0].name}\n**{this_team[1]} - {this_team[2]} WR: - GB: {this_team[4]}\n\n" + + this_embed.add_field(name=f"{subleague} League:", value=teams_string, inline = False) + + this_embed.set_footer(text=f"Wildcard standings as of day {self.day-1}") + return this_embed + + def champ_series(self): + tournaments = [] + standings = {} + + for team_name, wins, losses, run_diff in league_db.get_standings(self.name): + standings[team_name] = {"wins" : wins, "losses" : losses, "run_diff" : run_diff} + + for subleague in iter(self.league.keys()): + team_dic = {} + division_leaders = [] + subleague_array = [] + wildcard_leaders = [] + for division in iter(self.league[subleague].keys()): + division_leaders += self.division_standings(self.league[subleague][division], standings)[:self.constraints["division_leaders"]] + this_div_wildcard = [this_team for this_team, wins, losses, diff, gb in self.division_standings(self.league[subleague][division], standings)[self.constraints["division_leaders"]:]] + subleague_array += this_div_wildcard + if self.constraints["wild_cards"] > 0: + wildcard_leaders = self.division_standings(subleague_array, standings)[:self.constraints["wild_cards"]] + + for this_team, wins, losses, diff, gb in division_leaders + wildcard_leaders: + team_dic[this_team] = {"wins" : wins} + + subleague_tournament = tournament(f"{self.name} {subleague} Subleague Series", team_dic, series_length=3, finals_series_length=5, secs_between_games=int(3600/self.games_per_hour), secs_between_rounds=int(7200/self.games_per_hour)) + subleague_tournament.build_bracket(by_wins = True) + subleague_tournament.league = self + tournaments.append(subleague_tournament) + + return tournaments + + class tournament(object): def __init__(self, name, team_dic, series_length = 5, finals_series_length = 7, max_innings = 9, id = None, secs_between_games = 300, secs_between_rounds = 600): self.name = name @@ -191,6 +340,9 @@ class tournament(object): self.round_delay = secs_between_rounds self.finals = False self.id = id + self.league = None + self.winner = None + self.day = None if id is None: self.id = random.randint(1111,9999) @@ -288,7 +440,7 @@ def save_league(this_league): with open(os.path.join(data_dir, league_dir, this_league.name, f"{this_league.name}.league"), "w") as league_file: league_json_string = jsonpickle.encode(this_league.league, keys=True) json.dump(league_json_string, league_file, indent=4) - return True + league_db.save_league(this_league) def load_league_file(league_name): if league_db.league_exists(league_name): @@ -298,4 +450,18 @@ def load_league_file(league_name): this_league.league = jsonpickle.decode(json.load(league_file), keys=True, classes=team) with open(os.path.join(data_dir, league_dir, league_name, f"{this_league.name}.state")) as state_file: state_dic = json.load(state_file) + + this_league.day = state_dic["day"] + this_league.schedule = state_dic["schedule"] + this_league.constraints = state_dic["constraints"] + this_league.game_length = state_dic["game_length"] + this_league.series_length = state_dic["series_length"] + this_league.owner = state_dic["owner"] + this_league.games_per_hour = state_dic["games_per_hour"] + this_league.historic = state_dic["historic"] + this_league.season = state_dic["season"] + try: + this_league.champions = state_dic["champions"] + except: + this_league.champions = {} return this_league \ No newline at end of file diff --git a/main_controller.py b/main_controller.py index 73da0a7..8086dad 100644 --- a/main_controller.py +++ b/main_controller.py @@ -1,6 +1,9 @@ -import asyncio, time, datetime, games, json, threading, jinja2, leagues, os -from flask import Flask, url_for, Response, render_template, request, jsonify, send_from_directory +import asyncio, time, datetime, games, json, threading, jinja2, leagues, os, leagues +from leagues import league_structure +from league_storage import league_exists +from flask import Flask, url_for, Response, render_template, request, jsonify, send_from_directory, abort from flask_socketio import SocketIO, emit +import database as db app = Flask("the-prestige", static_folder='simmadome/build') app.config['SECRET KEY'] = 'dev' @@ -16,6 +19,59 @@ def serve(path): else: return send_from_directory(app.static_folder, 'index.html') +### API + +@app.route('/api/teams/search') +def search_teams(): + query = request.args.get('query') + page_len = int(request.args.get('page_len')) + page_num = int(request.args.get('page_num')) + + if query is None: + abort(400, "A query term is required") + + result = db.search_teams(query) + if page_len is not None: #pagination should probably be done in the sqlite query but this will do for now + if page_num is None: + abort(400, "A page_len argument must be accompanied by a page_num argument") + result = result[page_num*page_len : (page_num + 1)*page_len] + + return jsonify([json.loads(x[0])['name'] for x in result]) #currently all we need is the name but that can change + + +@app.route('/api/leagues', methods=['POST']) +def create_league(): + config = json.loads(request.data) + + if (league_exists(config['name'])): + abort(400, "A league by that name already exists") + + print(config) + league_dic = { + subleague['name'] : { + division['name'] : [games.get_team(team_name) for team_name in division['teams']] + for division in subleague['divisions'] + } + for subleague in config['structure']['subleagues'] + } + + new_league = league_structure(config['name']) + new_league.setup( + league_dic, + division_games=config['division_series'], + inter_division_games=config['inter_division_series'], + inter_league_games=config['inter_league_series'], + ) + new_league.constraints["division_leaders"] = config["top_postseason"] + new_league.constraints["wild_cards"] = config["wildcards"] + new_league.generate_schedule() + leagues.save_league(new_league) + + return "League created successfully" + + + +### SOCKETS thread2 = threading.Thread(target=socketio.run,args=(app,'0.0.0.0')) thread2.start() @@ -110,6 +166,8 @@ def update_loop(): if this_game.last_update[0]["defender"] != "": punc = ". " + + if "fc_out" in this_game.last_update[0].keys(): name, base_string = this_game.last_update[0]['fc_out'] updatestring = f"{this_game.last_update[0]['batter']} {this_game.last_update[0]['text'].value.format(name, base_string)} {this_game.last_update[0]['defender']}{punc}" @@ -120,6 +178,13 @@ def update_loop(): state["update_emoji"] = "🏏" state["update_text"] = updatestring + + if "veil" in this_game.last_update[0].keys(): + state["update_emoji"] = "🌌" + state["update_text"] += f" {this_game.last_update[0]['batter']}'s will manifests on {games.base_string(this_game.last_update[1])} base." + elif "error" in this_game.last_update[0].keys(): + state["update_emoji"] = "👻" + state["update_text"] = f"{this_game.last_update[0]['batter']}'s hit goes ethereal, and {this_game.last_update[0]['defender']} can't catch it! {this_game.last_update[0]['batter']} reaches base safely." state["bases"] = this_game.named_bases() diff --git a/simmadome/package-lock.json b/simmadome/package-lock.json index 9bb26d1..a27cb55 100644 --- a/simmadome/package-lock.json +++ b/simmadome/package-lock.json @@ -2276,6 +2276,15 @@ "pretty-format": "^26.0.0" } }, + "@types/jquery": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.5.tgz", + "integrity": "sha512-6RXU9Xzpc6vxNrS6FPPapN1SxSHgQ336WC6Jj/N8q30OiaBZ00l1GBgeP7usjVZPivSkGUfL1z/WW6TX989M+w==", + "dev": true, + "requires": { + "@types/sizzle": "*" + } + }, "@types/json-schema": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.6.tgz", @@ -2370,6 +2379,12 @@ "@types/node": "*" } }, + "@types/sizzle": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.2.tgz", + "integrity": "sha512-7EJYyKTL7tFR8+gDbB6Wwz/arpGa0Mywk1TJbNzKzHtzbwVmY4HR9WqS5VV7dsBUKQmPNr192jHr/VpBluj/hg==", + "dev": true + }, "@types/socket.io-client": { "version": "1.4.34", "resolved": "https://registry.npmjs.org/@types/socket.io-client/-/socket.io-client-1.4.34.tgz", @@ -9529,6 +9544,11 @@ } } }, + "jquery": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.5.1.tgz", + "integrity": "sha512-XwIBPqcMn57FxfT+Go5pzySnm4KWkT1Tv7gjrpT1srtf8Weynl6R273VJ5GjkRb51IzMp5nbaPjJXMWeju2MKg==" + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/simmadome/package.json b/simmadome/package.json index 087a124..104db30 100644 --- a/simmadome/package.json +++ b/simmadome/package.json @@ -4,6 +4,7 @@ "private": true, "proxy": "http://localhost:5000", "dependencies": { + "jquery": "^3.5.1", "react": "^17.0.1", "react-dom": "^17.0.1", "react-router": "^5.2.0", @@ -19,6 +20,7 @@ "@testing-library/react": "^11.2.2", "@testing-library/user-event": "^12.6.0", "@types/jest": "^26.0.19", + "@types/jquery": "^3.5.5", "@types/node": "^12.19.12", "@types/react": "^16.14.2", "@types/react-dom": "^16.9.10", diff --git a/simmadome/src/CreateLeague.css b/simmadome/src/CreateLeague.css new file mode 100644 index 0000000..90c680b --- /dev/null +++ b/simmadome/src/CreateLeague.css @@ -0,0 +1,310 @@ +.cl_table_header > .cl_subleague_bg { + border-top-left-radius: 0.5rem; + border-top-right-radius: 0.5rem; + padding-top: 1rem; +} + +.cl_league_structure_table .cl_table_row:last-child .cl_subleague_bg { + border-bottom-left-radius: 0.5rem; + border-bottom-right-radius: 0.5rem; + padding-bottom: 1rem; +} + +.cl_league_structure_table .cl_table_row:last-child .cl_division_delete { + margin-bottom: 0.5rem; +} + +input { + border: none; + border-radius: 1rem; + height: 2rem; + padding-left: 1rem; + background: var(--background-secondary); + font-size: 14pt; +} + +input:focus { + outline: none; +} + +input[type="number"] { + -webkit-appearance: textfield; + -moz-appearance: textfield; + appearance: textfield; +} +input[type=number]::-webkit-inner-spin-button, +input[type=number]::-webkit-outer-spin-button { + -webkit-appearance: none; +} + +.cl_league_main { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + margin-top: 3rem; +} + +.cl_league_name { + margin-top: 1rem; +} + +.cl_league_options, .cl_league_structure, .cl_confirm_box { + display: flex; + background: var(--background-tertiary); + flex-direction: column; + max-width: 100%; + border-radius: 1rem; + padding-top: 1.5rem; +} + +.cl_confirm_box { + min-width: 55rem; + padding: 2.5rem; + display: flex; + align-items: center; + justify-content: center; + font-size: 20pt; +} + +.cl_league_options { + align-items: center; +} + +.cl_league_structure, .cl_subleague_add_align { + display: flex; + align-items: center; + justify-content: center; + width: min-content; +} + +.cl_league_structure { + margin-top: 1rem; +} + +.cl_league_structure_table { + display: table; + margin-right: 1rem; +} + +.cl_headers, .cl_table_row { + display: table-row; + height: min-content; +} + +.cl_table_header, .cl_delete_filler, .cl_delete_box, .cl_division_cell { + display: table-cell; + height:100%; +} + +.cl_delete_box { + vertical-align: middle; +} + +.cl_league_structure_scrollbox { + max-width: 100%; + overflow-y: scroll; +} + + /* Hide scrollbar for Chrome, Safari and Opera */ +.cl_league_structure_scrollbox::-webkit-scrollbar { + display: none; +} + +/* Hide scrollbar for IE, Edge and Firefox */ +.cl_league_structure_scrollbox { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ +} + +.cl_subleague_add_align{ + margin-left: 1.5rem; + padding-right: 1.5rem; +} + +.cl_subleague_header { + display: flex; + width:100%; + align-items: center; + justify-content: space-between; +} + +.cl_subleague_bg { + background: var(--background-main); + padding: 0.5rem 1rem; + margin: 0rem 0.5rem; + min-width: 22rem; + height: 100%; + box-sizing: border-box; +} + +.cl_subleague_name { + flex-grow: 1; + margin-right: 0.5rem; +} + +.cl_division_name_box { + width: 100%; +} + +.cl_division_name { + margin-bottom: 0.5rem; + width: 95%; +} + +.cl_newteam_name { + width: 95%; +} + +.cl_division { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 1rem; + border-radius: 0.5rem; + background: var(--background-accent); +} + +.cl_team, .cl_team_add { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + margin: 0.4rem 0rem; +} + +.cl_team_name { + font-size: 14pt; + padding: 0 0.5rem; + overflow: hidden; + text-overflow: ellipsis; +} + +.cl_team_add { + display: flex; + flex-direction: column; + margin-bottom: 0rem; +} + +.cl_search_list { + width: 95%; + margin-top: 0.6rem; + padding: 0.5rem; + background: var(--background-tertiary); + border-radius: 0.5rem; +} + +.cl_search_result { + padding: 0.2rem 0.4rem; + border-radius: 0.5rem; +} + +.cl_search_result:hover { + background: var(--background-main); +} + +.cl_league_options { + padding: 1rem; + margin-top: 1.5rem; + width: 55rem; + box-sizing: border-box; +} + +.cl_option_main { + display: flex; + flex-direction: row; + justify-content: space-around; + width: 100%; +} + +.cl_option_submit_box { + display: flex; + flex-direction: column; + align-items: center; +} + +.cl_option_box { + margin: 1rem; + width: max-content; +} + +.cl_option_label, .cl_option_err, .cl_structure_err { + margin: 0.25rem; +} + +.cl_option_err, .cl_structure_err { + color: var(--accent-red); +} + +.cl_option_err { + min-height: 1.5rem; + margin-bottom: -0.5rem; + margin-top: 0.5rem; +} + +.cl_structure_err { + margin-bottom: -0.5rem; +} + +.cl_structure_err_div { + margin-top: -0.5rem; + margin-bottom: 0; +} + +.cl_structure_err_teams { + width: 98%; +} + +/* button styles */ + +button > .emoji { + margin: 0; + width: 1rem; + height: 1rem; +} + +.cl_subleague_delete, .cl_team_delete, .cl_division_delete, .cl_subleague_add, .cl_division_add { + padding: 0; + width: 2rem; + height: 2rem; + border: none; + border-radius: 1rem; + display: flex; + align-items: center; + justify-content: center; +} + +.cl_subleague_delete, .cl_team_delete, .cl_division_delete { + background: var(--accent-red); +} + +.cl_subleague_add, .cl_division_add { + background: var(--accent-green); +} + +.cl_subleague_add { + position: relative; + top: 1.6rem; +} + +.cl_division_add { + margin-top: 1.25rem; + margin-bottom: 1.25rem; +} + +.cl_delete_filler { + min-width: 3rem; +} + +.cl_option_submit { + padding: 1rem 2rem; + height: 2rem; + border: none; + border-radius: 0.75rem; + display: flex; + align-items: center; + justify-content: center; + background: var(--accent-green); + font-size: 14pt; +} \ No newline at end of file diff --git a/simmadome/src/CreateLeague.tsx b/simmadome/src/CreateLeague.tsx new file mode 100644 index 0000000..29d0d6c --- /dev/null +++ b/simmadome/src/CreateLeague.tsx @@ -0,0 +1,463 @@ +import React, {useState, useRef, useLayoutEffect, useReducer} from 'react'; +import {removeIndex, replaceIndex, append, arrayOf, shallowClone, getUID, DistributiveOmit} from './util'; +import './CreateLeague.css'; +import twemoji from 'twemoji'; + +// STATE CLASSES + +class LeagueStructureState { + subleagues: SubleagueState[] + + constructor(subleagues: SubleagueState[] = []) { + this.subleagues = subleagues; + } +} + +class SubleagueState { + name: string + divisions: DivisionState[] + id: string|number + + constructor(divisions: DivisionState[] = []) { + this.name = ""; + this.divisions = divisions; + this.id = getUID(); + } +} + +class DivisionState { + name: string + teams: TeamState[] + id: string|number + + constructor() { + this.name = ""; + this.teams = []; + this.id = getUID(); + } +} + +class TeamState { + name: string + id: string|number + + constructor(name: string = "") { + this.name = name; + this.id = getUID(); + } +} + +// STRUCTURE REDUCER + +type StructureReducerActions = + {type: 'remove_subleague', subleague_index: number} | + {type: 'add_subleague'} | + {type: 'rename_subleague', subleague_index: number, name: string} | + {type: 'remove_divisions', division_index: number} | + {type: 'add_divisions'} | + {type: 'rename_division', subleague_index: number, division_index: number, name: string} | + {type: 'remove_team', subleague_index: number, division_index: number, name:string} | + {type: 'add_team', subleague_index:number, division_index:number, name:string} + +function leagueStructureReducer(state: LeagueStructureState, action: StructureReducerActions): LeagueStructureState { + switch (action.type) { + case 'remove_subleague': + return {subleagues: removeIndex(state.subleagues, action.subleague_index)}; + case 'add_subleague': + return {subleagues: append(state.subleagues, new SubleagueState( + arrayOf(state.subleagues[0].divisions.length, i => + new DivisionState() + ) + ))} + case 'rename_subleague': + return replaceSubleague(state, action.subleague_index, subleague => { + let nSubleague = shallowClone(subleague); + nSubleague.name = action.name; + return nSubleague; + }); + case 'remove_divisions': + return {subleagues: state.subleagues.map(subleague => { + let nSubleague = shallowClone(subleague); + nSubleague.divisions = removeIndex(subleague.divisions, action.division_index) + return nSubleague; + })}; + case 'add_divisions': + return {subleagues: state.subleagues.map(subleague => { + let nSubleague = shallowClone(subleague); + nSubleague.divisions = append(subleague.divisions, new DivisionState()) + return nSubleague; + })}; + case 'rename_division': + return replaceDivision(state, action.subleague_index, action.division_index, division => { + let nDivision = shallowClone(division); + nDivision.name = action.name; + return nDivision; + }); + case 'remove_team': + return replaceDivision(state, action.subleague_index, action.division_index, division => { + let nDivision = shallowClone(division); + nDivision.teams = removeIndex(division.teams, division.teams.findIndex(val => val.name === action.name)); + return nDivision; + }); + case 'add_team': + return replaceDivision(state, action.subleague_index, action.division_index, division => { + let nDivision = shallowClone(division); + nDivision.teams = append(division.teams, new TeamState(action.name)); + return nDivision; + }); + } +} + +function replaceSubleague(state: LeagueStructureState, si: number, func: (val: SubleagueState) => SubleagueState) { + return {subleagues: replaceIndex(state.subleagues, si, func(state.subleagues[si]))} +} + +function replaceDivision(state: LeagueStructureState, si: number, di: number, func:(val: DivisionState) => DivisionState) { + return replaceSubleague(state, si, subleague => { + let nSubleague = shallowClone(subleague); + nSubleague.divisions = replaceIndex(subleague.divisions, di, func(subleague.divisions[di])); + return nSubleague; + }); +} + +// OPTIONS REDUCER + +class LeagueOptionsState { + games_series = "3" + intra_division_series = "8" + inter_division_series = "16" + inter_league_series = "8" + top_postseason = "1" + wildcards = "0" +} + +type OptionsReducerActions = + {type: 'set_games_series', value: string} | + {type: 'set_intra_division_series', value: string} | + {type: 'set_inter_division_series', value: string} | + {type: 'set_inter_league_series', value: string} | + {type: 'set_top_postseason', value: string} | + {type: 'set_wildcards', value: string} + +function LeagueOptionsReducer(state: LeagueOptionsState, action: OptionsReducerActions) { + let newState = shallowClone(state); + switch (action.type) { + case 'set_games_series': + newState.games_series = action.value; + break; + case 'set_intra_division_series': + newState.intra_division_series = action.value; + break; + case 'set_inter_division_series': + newState.inter_division_series = action.value; + break; + case 'set_inter_league_series': + newState.inter_league_series = action.value; + break; + case 'set_top_postseason': + newState.top_postseason = action.value; + break; + case 'set_wildcards': + newState.wildcards = action.value; + break; + } + return newState +} + +// CREATE LEAGUE + +let initLeagueStructure = { + subleagues: [0, 1].map((val) => + new SubleagueState([0, 1].map((val) => + new DivisionState() + )) + ) +}; + +function CreateLeague() { + let [name, setName] = useState(""); + let [showError, setShowError] = useState(false); + let [nameExists, setNameExists] = useState(false); + let [createSuccess, setCreateSuccess] = useState(false); + let [structure, structureDispatch] = useReducer(leagueStructureReducer, initLeagueStructure); + let [options, optionsDispatch] = useReducer(LeagueOptionsReducer, new LeagueOptionsState()); + + let self = useRef(null) + + useLayoutEffect(() => { + if (self.current) { + twemoji.parse(self.current) + } + }) + + if (createSuccess) { + return( +
+
+ League created succesfully! +
+
+ ); + } + + return ( +
+ { + setName(e.target.value); + setNameExists(false); + }}/> +
{ + name === "" && showError ? "A name is required." : + nameExists && showError ? "A league by that name already exists" : + "" + }
+ +
+ +
+ +
{ + !validRequest(name, structure, options) && showError ? + "Cannot create league. Some information is missing or invalid." : "" + }
+
+
+
+ ); +} + +function makeRequest(name:string, structure: LeagueStructureState, options:LeagueOptionsState) { + return JSON.stringify({ + name: name, + structure: { + subleagues: structure.subleagues.map(subleague => ({ + name: subleague.name, + divisions: subleague.divisions.map(division => ({ + name: division.name, + teams: division.teams.map(team => team.name) + })) + })) + }, + games_per_series: Number(options.games_series), + division_series: Number(options.intra_division_series), + inter_division_series: Number(options.inter_division_series), + inter_league_series: Number(options.inter_league_series), + top_postseason: Number(options.top_postseason), + wildcards: Number(options.wildcards) + }); +} + +function validRequest(name:string, structure: LeagueStructureState, options:LeagueOptionsState) { + return ( + name !== "" && + validNumber(options.games_series) && + validNumber(options.intra_division_series) && + validNumber(options.inter_division_series) && + validNumber(options.inter_league_series) && + validNumber(options.top_postseason) && + validNumber(options.wildcards, 0) && + structure.subleagues.length % 2 === 0 && + structure.subleagues.every(subleague => + subleague.name !== "" && + subleague.divisions.every(division => + division.name !== "" && + division.teams.length >= 2 + ) + ) + ) +} + +function validNumber(value: string, min = 1) { + return !isNaN(Number(value)) && Number(value) >= min +} + +// LEAGUE STRUCUTRE + +function LeagueStructre(props: {state: LeagueStructureState, dispatch: React.Dispatch, showError: boolean}) { + return ( +
+
+
+
+ + +
+ +
+
+
{props.state.subleagues.length % 2 !== 0 && props.showError ? "Must have an even number of subleagues." : ""}
+ +
+ ); +} + +function SubleagueHeaders(props: {subleagues: SubleagueState[], dispatch: React.Dispatch, showError:boolean}) { + return ( +
+
+ {props.subleagues.map((subleague, i) => ( +
+
+ 1} dispatch={action => + props.dispatch(Object.assign({subleague_index: i}, action)) + }/> +
{subleague.name === "" && props.showError ? "A name is required." : ""}
+
+
+ ))} +
+ ); +} + +function SubleageHeader(props: {state: SubleagueState, canDelete: boolean, dispatch:(action: DistributiveOmit) => void}) { + return ( +
+ + props.dispatch({type: 'rename_subleague', name: e.target.value}) + }/> + {props.canDelete ? : null} +
+ ); +} + +function Divisions(props: {subleagues: SubleagueState[], dispatch: React.Dispatch, showError: boolean}) { + return (<> + {props.subleagues[0].divisions.map((val, di) => ( +
+
+ {props.subleagues[0].divisions.length > 1 ? + : + null + } +
+ {props.subleagues.map((subleague, si) => ( +
+
+ + props.dispatch(Object.assign({subleague_index: si, division_index: di}, action)) + } showError={props.showError}/> +
+
+ ))} +
+ ))} + ); +} + +function Division(props: {state: DivisionState, dispatch:(action: DistributiveOmit) => void, showError:boolean}) { + let [newName, setNewName] = useState(""); + let [searchResults, setSearchResults] = useState([]); + let newNameInput = useRef(null); + let resultList = useRef(null); + + useLayoutEffect(() => { + if (resultList.current) { + twemoji.parse(resultList.current) + } + }) + + return ( +
+
+ + props.dispatch({type: 'rename_division', name: e.target.value}) + }/> +
{props.state.name === "" && props.showError ? "A name is required." : ""}
+
+ {props.state.teams.map((team, i) => ( +
+
{team.name}
+ +
+ ))} +
+ { + let params = new URLSearchParams({query: e.target.value, page_len: '5', page_num: '0'}); + fetch("/api/teams/search?" + params.toString()) + .then(response => response.json()) + .then(data => setSearchResults(data)); + setNewName(e.target.value); + }}/> +
+ {searchResults.length > 0 && newName.length > 0 ? + (
+ {searchResults.map(result => +
{ + props.dispatch({type:'add_team', name: result}); + setNewName(""); + if (newNameInput.current) { + newNameInput.current.focus(); + } + }}>{result}
+ )} +
): +
+ } +
{props.state.teams.length < 2 && props.showError ? "Must have at least 2 teams." : ""}
+
+ ); +} + +// LEAGUE OPTIONS + +function LeagueOptions(props: {state: LeagueOptionsState, dispatch: React.Dispatch, showError: boolean}) { + return ( +
+
+ + props.dispatch({type: 'set_games_series', value: value})} showError={props.showError}/> + + props.dispatch({type: 'set_top_postseason', value: value})} showError={props.showError}/> + + props.dispatch({type: 'set_wildcards', value: value})} showError={props.showError}/> +
+
+ + props.dispatch({type: 'set_intra_division_series', value: value})} showError={props.showError}/> + + props.dispatch({type: 'set_inter_division_series', value: value})} showError={props.showError}/> + + props.dispatch({type: 'set_inter_league_series', value: value})} showError={props.showError}/> +
+
+ ); +} + +function NumberInput(props: {title: string, value: string, setValue: (newVal: string) => void, showError: boolean, minValue?:number}) { + let minValue = 1; + if (props.minValue !== undefined) { + minValue = props.minValue + } + return ( +
+
{props.title}
+ props.setValue(e.target.value)}/> +
{(!isNaN(Number(props.value)) || Number(props.value) < minValue) && props.showError ? "Must be a number greater than "+minValue : ""}
+
+ ); +} + +export default CreateLeague; \ No newline at end of file diff --git a/simmadome/src/Game.css b/simmadome/src/Game.css index fa2af13..ca73b4d 100644 --- a/simmadome/src/Game.css +++ b/simmadome/src/Game.css @@ -1,5 +1,4 @@ .game { - align-self: stretch; text-align: center; display: flex; flex-direction: column; diff --git a/simmadome/src/GamePage.css b/simmadome/src/GamePage.css index 69f0cc7..e07cd9a 100644 --- a/simmadome/src/GamePage.css +++ b/simmadome/src/GamePage.css @@ -3,5 +3,35 @@ margin-left: 1rem; margin-right: 1rem; display: flex; + flex-direction: column; + align-items: center; justify-content: space-around; +} + +.history_box { + width: 100%; + margin-top: 3rem; + padding: 1rem; + padding-top: 0.5rem; + background: var(--background-main); + border-radius: 0.25rem; + width: 100%; + min-width: 32rem; + max-width: 44rem; + box-sizing: border-box; + border: 4px solid; + border-radius: 4px; + border-color: var(--highlight); + border-top: none; + border-right: none; + border-bottom: none; +} + +.history_title { + font-size: 14pt; +} + +.history_update { + height: 4rem; + margin: 0.5rem; } \ No newline at end of file diff --git a/simmadome/src/GamePage.tsx b/simmadome/src/GamePage.tsx index 47839a3..cdf2c31 100644 --- a/simmadome/src/GamePage.tsx +++ b/simmadome/src/GamePage.tsx @@ -1,22 +1,62 @@ -import React, {useState} from 'react'; +import React, {useState, useRef, useLayoutEffect} from 'react'; +import twemoji from 'twemoji'; import ReactRouter from 'react-router'; import {GameState, useListener} from './GamesUtil'; import './GamePage.css'; import Game from './Game'; +import {getUID} from './util'; function GamePage(props: ReactRouter.RouteComponentProps<{id: string}>) { - let [games, setGames] = useState<[string, GameState][]>([]); - useListener((newGames) => setGames(newGames)); + let [game, setGame] = useState<[string, GameState]|undefined>(undefined); + let history = useRef<[number, string, string][]>([]); + + useListener((newGames) => { + let newGame = newGames.find((gamePair) => gamePair[0] === props.match.params.id); + setGame(newGame); + console.log(newGame); + if (newGame !== undefined && newGame[1].start_delay < 0 && newGame[1].end_delay > 8) { + history.current.unshift([getUID(), newGame[1].update_emoji, newGame[1].update_text]); + if (history.current.length > 8) { + history.current.pop(); + } + } + }); + + if (game === undefined) { + return
The game you're looking for either doesn't exist or has already ended.
+ } - let game = games.find((game) => game[0] === props.match.params.id) return (
- { game ? - : - "The game you're looking for either doesn't exist or has already ended." + + { history.current.length > 0 ? + : + null }
); } +function GameHistory(props: {history: [number, string, string][]}) { + let self = useRef(null); + + useLayoutEffect(() => { + if (self.current) { + twemoji.parse(self.current); + } + }) + + return ( +
+
History
+ {props.history.map((update) => ( +
+
{update[1]}
+
{update[2]}
+
+ ))} +
+ ); +} + export default GamePage; \ No newline at end of file diff --git a/simmadome/src/GamesPage.css b/simmadome/src/GamesPage.css index d99b790..8baef47 100644 --- a/simmadome/src/GamesPage.css +++ b/simmadome/src/GamesPage.css @@ -76,9 +76,4 @@ left: 50%; transform: translate(-50%, 0); } - - .emptyslot { - border: none; - min-height: 0px; - } } diff --git a/simmadome/src/GamesPage.tsx b/simmadome/src/GamesPage.tsx index 3a2ad97..4fdaacb 100644 --- a/simmadome/src/GamesPage.tsx +++ b/simmadome/src/GamesPage.tsx @@ -8,6 +8,7 @@ function GamesPage() { let [search, setSearch] = useState(window.location.search); useEffect(() => { setSearch(window.location.search); + //eslint-disable-next-line react-hooks/exhaustive-deps }, [window.location.search]) let searchparams = new URLSearchParams(search); diff --git a/simmadome/src/GamesUtil.tsx b/simmadome/src/GamesUtil.tsx index 8002c02..57f0248 100644 --- a/simmadome/src/GamesUtil.tsx +++ b/simmadome/src/GamesUtil.tsx @@ -20,6 +20,8 @@ interface GameState { update_text: string is_league: boolean leagueoruser: string + start_delay: number + end_delay: number } type GameList = ([id: string, game: GameState] | null)[]; @@ -32,6 +34,7 @@ const useListener = (onUpdate: (update: [string, GameState][]) => void, url: str socket.on('connect', () => socket.emit('recieved', {})); socket.on('states_update', onUpdate); return () => {socket.disconnect()}; + //eslint-disable-next-line react-hooks/exhaustive-deps }, [url]) } diff --git a/simmadome/src/img/github.png b/simmadome/src/img/github.png new file mode 100644 index 0000000..73db1f6 Binary files /dev/null and b/simmadome/src/img/github.png differ diff --git a/simmadome/src/img/patreon.png b/simmadome/src/img/patreon.png new file mode 100644 index 0000000..9a521e3 Binary files /dev/null and b/simmadome/src/img/patreon.png differ diff --git a/simmadome/src/img/twitter.png b/simmadome/src/img/twitter.png new file mode 100755 index 0000000..af44ca5 Binary files /dev/null and b/simmadome/src/img/twitter.png differ diff --git a/simmadome/src/index.css b/simmadome/src/index.css index 663440d..9cf2b34 100644 --- a/simmadome/src/index.css +++ b/simmadome/src/index.css @@ -58,23 +58,51 @@ h2 { #link_div { text-align: right; position: absolute; - top: 0px; - right: 30px; + top: 1rem; + right: 2rem; + display: flex; } -#link_div > a { +.github_logo, .twitter_logo, .patreon_container { + height: 2rem; + width: 2rem; + margin-left: 0.75rem; +} + +.patreon_container { + border-radius: 1rem; + background: #FF424D; +} + +.patreon_logo { + box-sizing: border-box; + padding: 0.35rem; + height: 2rem; + width: 2rem; + position: relative; + left: 0.1rem; + bottom: 0.05rem; +} + +a { background-color: transparent; text-decoration: underline; } -#link_div > a:link, #link_div > a:visited { +a:link, a:visited { color: lightblue; } -#link_div > a:hover { +a:hover { color: white; } +#utility_links { + position: absolute; + top: 1rem; + left: 2rem; +} + img.emoji { height: 1em; width: 1em; diff --git a/simmadome/src/index.tsx b/simmadome/src/index.tsx index 1235dcc..b0ec638 100644 --- a/simmadome/src/index.tsx +++ b/simmadome/src/index.tsx @@ -1,11 +1,15 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import { BrowserRouter as Router, Switch, Route } from 'react-router-dom'; +import { BrowserRouter as Router, Switch, Route, Link } from 'react-router-dom'; import './index.css'; import GamesPage from './GamesPage'; import GamePage from './GamePage'; +import CreateLeague from './CreateLeague'; import discordlogo from "./img/discord.png"; import reportWebVitals from './reportWebVitals'; +import patreonLogo from './img/patreon.png'; +import githubLogo from './img/github.png'; +import twitterLogo from './img/twitter.png'; ReactDOM.render( @@ -13,6 +17,7 @@ ReactDOM.render(
+ @@ -20,13 +25,25 @@ ReactDOM.render( document.getElementById('root') ); + function Header() { return (