Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Concurrent Tournament Runner #1

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions server/player.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ def __init__(self, websocket, username):
self.websocket = websocket
self.username = username
self.lock = asyncio.Lock()

def __eq__(self, other):
if not isinstance(other, Player):
return False
# Rely on the fact that usernames are unique on the server
return self.username == other.username

def __hash__(self):
# Rely on the fact that usernames are unique on the server
return hash(self.username)

async def send_message(self, message):
await self.websocket.send(message)
Expand Down Expand Up @@ -109,6 +119,12 @@ async def send_invalid_message(self):
"""
await self.websocket.send(json.dumps({ "type": "invalid_request" }))

def is_locked(self):
"""
Returns if the player is currently locked in a game.
"""
return self.lock.locked()

class GameController:
"""
Representation:
Expand Down Expand Up @@ -142,6 +158,15 @@ def __init__(self, player1: Player, player2: Player):

self.id = str(uuid.uuid4())

def __eq__(self, other):
if (not isinstance(other, GameController)):
return False
else:
return self.get_id() == other.get_id()

def __hash__(self):
return hash(self.get_id())

def get_id(self):
"""
Returns a id unique to this game
Expand Down
4 changes: 2 additions & 2 deletions server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import json
from player import Player, GameController
from autoscrim import autoscrim
from tournament_runner import run_tourney
from tournament import run_tournament
import os.path

# current console command
Expand Down Expand Up @@ -86,7 +86,7 @@ async def main():
# determine which mode we are in, then run appropriate code
check_mode()
if server_mode == SERVER_MODES[1]:
await run_tourney(players)
await run_tournament(players)
# we want to be in autoscrim mode by default
else:
await autoscrim(players)
Expand Down
229 changes: 229 additions & 0 deletions server/tournament.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import asyncio
import random
from typing import Literal, TypedDict
from player import GameController, Player

class PlayerStats(TypedDict):
won: int
tied: int
lost: int
played: int

class RankingStats(PlayerStats):
username: str
points: int

def generate_queue(players: dict[str, Player]) -> set[GameController]:
"""
Generates a queue of all possible games (unordered pairings) between the given players.

Arguments:
players: a dictionary mapping player usernames (strings) to unique Player objects

Returns:
a set of GameControllers representing all possible games
"""
usernames = list(players.keys())
queue = set()
for player_name in usernames:
opponents = usernames.copy()
opponents.remove(player_name)
for opponent in opponents:
queue.add(GameController(players[player_name], players[opponent]))
return queue

def rank_sort(statistics: dict[str, PlayerStats]) -> list[RankingStats]:
"""
Calculates the score of players in a tournament and ranks them as follows:
points (3 for a win, 1 for a tie),
number of wins,
number of ties,
random tiebreaker

Arguments:
statistics: dict[str, PlayerStats] - key: username, value: PlayerStats object

Returns:
an ordered list of RankingStats objects representing the ranking order of players
"""
rankings: list[RankingStats] = [ { "username": name, "points": 0, **stats } for name, stats in statistics.items() ]
for player in rankings:
player["points"] = player["won"] * 3 + player["tied"]
rankings.sort(key=lambda player: (player["points"], player["won"], player["tied"], random.random()), reverse=True)
return rankings

class Tournament:
"""
Representation:
players: dict[str, Player] - key: username, value: Player object
available_players: set[Player] - set of Player objects that are not currently in a game
statistics: dict[str, PlayerStats] - key: username, value: PlayerStats object
length: int - number of games that will be played
game_queue: set[GameController] - set of GameController objects representing all games still needing to be played
completed_games: set[GameController] - set of GameController objects representing all games that have been played
cancelled: bool - whether the tournament has been cancelled

Functions:
constructor: creates a new complete round-robin tournament with the given players
start(): starts the tournament, running all games and updating rankings
start_game(): starts a single game in the tournament
update_statistics(): updates player statistics based on the results of a given game
compute_status(): returns the current status of the tournament
cancel(): cancels the tournament
print_rankings(): prints out a ranking of players in a formatted fashion
"""

def __init__(self, players: dict[str, Player]) -> None:
# Check that the input is valid
if players is None or not isinstance(players, dict) or len(players) < 2:
print("Error: cannot create tournament with not enough players.")
self.players: dict[str, Player] = dict()
self.available_players: set[Player] = set()
self.statistics: dict[str, PlayerStats] = dict()
self.game_queue: set[GameController] = set()
self.length: int = len(self.game_queue)
self.completed_games: set[GameController] = set()
self.cancelled: bool = True
return
# Create the tournament
print("Notice: Creating tournament...")
self.players: dict[str, Player] = { username: player for username, player in players.items() }
self.available_players: set[Player] = set(self.players.values())
self.statistics: dict[str, PlayerStats] = { username: { "won": 0, "tied": 0, "lost": 0, "played": 0} for username in players }
self.game_queue: set[GameController] = generate_queue(players)
self.length: int = len(self.game_queue)
self.completed_games: set[GameController] = set()
self.cancelled: bool = False
print("Notice: Tournament created.")

async def start(self):
"""
Runs a complete round-robin tournament.

Prints out the ranking of players in a formatted fashion.

If not enough players are present, prints that to the console.
"""
# Ensure that there are enough players to start the tournament
if len(self.players) < 2:
print(f"Error: cannot start tournament with {len(self.players)} players.")
return
# Start the tournament
print("Notice: Starting tournament...")
self.print_rankings()
# Run all games
while self.compute_status() == "running":
if len(self.available_players) > 0:
# Start any games that are ready to be played
for game in self.game_queue.copy():
if game.player1 in self.available_players and game.player2 in self.available_players:
# Set this game to run concurrently
print(f"Notice: Starting game between {game.player1.username} and {game.player2.username}.")
self.game_queue.remove(game)
self.available_players.remove(game.player1)
self.available_players.remove(game.player2)
asyncio.create_task(self.start_game(game))
await asyncio.sleep(0)
# Yield control to the event loop for a bit
await asyncio.sleep(0)
# Print the final rankings
self.print_rankings()
return

async def start_game(self, game: GameController) -> None:
"""
Plays a single game in the tournament. Assumes that both players are not currently in another game.
Updates the rankings based on the results of the given completed GameController.

Arguments:
game: GameController - the game to begin
"""
await game.play_game()
self.update_statistics(game)
self.completed_games.add(game)
self.available_players.add(game.player1)
self.available_players.add(game.player2)
print(f"Notice: Game between {game.player1.username} and {game.player2.username} finished.")
return

def update_statistics(self, game: GameController) -> None:
"""
Updates player statistics based on the results of a completed game.

Arguments:
game: GameController - the completed game to update statistics for
"""
assert(game.is_game_over())
player1, player2 = game.player1.username, game.player2.username
results = game.get_results()
self.statistics[player1]["played"] += 1
self.statistics[player2]["played"] += 1
# Handle outcome
winner = results[0]
# If winner is None, the game was a tie
if not winner:
self.statistics[player1]["tied"] += 1
self.statistics[player2]["tied"] += 1
else:
loser = player2 if winner == player1 else player1
self.statistics[winner]["won"] += 1
self.statistics[loser]["lost"] += 1
return

def compute_status(self) -> Literal["running", "finished", "cancelled"]:
"""
Returns:
- "running" if the tournament is still running
- "finished" if the tournament is finished
- "cancelled" if the tournament was cancelled
"""
if self.cancelled: return "cancelled"
elif len(self.completed_games) != self.length: return "running"
else: return "finished"

def cancel(self) -> None:
"""
Cancels the tournament.
"""
status = self.compute_status()
if status == "cancelled":
print("Notice: tournament already cancelled.")
elif status == "finished":
print("Notice: tournament already finished.")
else:
print("Notice: cancelling tournament.")
self.cancelled = True

def print_rankings(self) -> None:
"""
Prints out the ranking of players in a formatted fashion.
"""
status = self.compute_status()
rankings = rank_sort(self.statistics)
if status == "cancelled":
print("Notice: Tournament was cancelled.")
else:
print(f"Notice: Tournament is currently {status}.\n")
print(f"Rankings:\n")
print("{:<15} {:<10} {:<5} {:<10} {:<5}".format('Username', 'Points', 'Wins', 'Losses', 'Ties'))
for ranking in rankings:
print("{:<15} {:<10} {:<5} {:<10} {:<5}".format(ranking['username'], ranking['points'], ranking['won'], ranking['lost'], ranking['tied']))

async def available_wait(self) -> None:
"""
Waits for a pair of players to be available.
"""
while len(self.available_players) < 2:
await asyncio.sleep(3)

async def run_tournament(players: dict[str, Player]) -> None:
"""
Runs a complete round-robin tournament.

Arguments:
players: a dictionary mapping player usernames (strings) to unique Player objects
"""
tournament = Tournament(players)
if not tournament.cancelled:
await tournament.start()
return