Skip to content
Snippets Groups Projects
Commit 18ce03fa authored by Fabian Heinrich's avatar Fabian Heinrich
Browse files

Docstrings

parent 1b72bbdf
No related branches found
No related tags found
1 merge request!72Resolve "Too large number of selected players does not break the gui and environment"
Pipeline #47838 passed
......@@ -32,7 +32,7 @@ from pydantic import BaseModel
from cooperative_cuisine import ROOT_DIR
from cooperative_cuisine.environment import EnvironmentConfig
from cooperative_cuisine.game_server import CreateEnvironmentConfig, EnvironmentData
from cooperative_cuisine.server_results import PlayerInfo
from cooperative_cuisine.server_results import PlayerInfo, CreateEnvResult
from cooperative_cuisine.utils import (
url_and_port_arguments,
add_list_of_manager_ids_arguments,
......@@ -62,7 +62,6 @@ class LevelConfig(BaseModel):
class LevelInfo(BaseModel):
name: str
last_level: bool
recipes: list[str]
recipe_graphs: list[dict]
......@@ -72,8 +71,8 @@ class StudyConfig(BaseModel):
num_bots: int
class StudyState:
def __init__(self, study_config_path: str | Path, game_url, game_port):
class Study:
def __init__(self, study_config_path: str | Path, game_url: str, game_port: int):
with open(study_config_path, "r") as file:
env_config_f = file.read()
......@@ -85,55 +84,70 @@ class StudyState:
"""List of level configs for each of the levels which the study runs through."""
self.current_level_idx: int = 0
"""Counter of which level is currently run in the config."""
self.participant_id_to_player_info = {}
self.participant_id_to_player_info: dict[str, PlayerInfo] = {}
"""A dictionary which maps participants to player infos."""
self.num_connected_players: int = 0
"""Number of currently connected players."""
self.current_running_env: EnvironmentData | None = None
self.current_running_env: CreateEnvResult | None = None
"""Information about the current running environment."""
self.players_done = {}
self.participants_done: dict[str, bool] = {}
"""A dictionary which saves which player has sent ready."""
self.current_config: dict | None = None
"""Save current environment config"""
self.use_aaambos_agent = False
self.websocket_url = f"ws://{game_url}:{game_port}/ws/player/"
self.sub_processes = []
self.current_item_info = None
self.current_config = None
self.use_aaambos_agent: bool = False
"""Use aaambos-agents or simple python scripts."""
self.bot_websocket_url: str = f"ws://{game_url}:{game_port}/ws/player/"
"""The websocket url for the bots to use."""
self.sub_processes: list[Popen] = []
"""Save subprocesses of the bots to be able to kill them afterwards."""
@property
def study_done(self):
def study_done(self) -> bool:
return self.current_level_idx >= len(self.levels)
@property
def last_level(self):
def last_level(self) -> bool:
return self.current_level_idx >= len(self.levels) - 1
@property
def is_full(self):
def is_full(self) -> bool:
return (
len(self.participant_id_to_player_info) == self.study_config["num_players"]
)
def can_add_participants(self, num_participants: int) -> bool:
"""Checks whether the number of participants fit in this study.
Args:
num_participants: Number of participants wished to be added.
Returns: True of the participants fit in this study, False if not.
"""
filled = (
self.num_connected_players + num_participants
<= self.study_config["num_players"]
)
return filled and not self.is_full
def create_env(self, level):
def create_env(self, level: LevelConfig) -> EnvironmentData:
"""Creates/starts an environment on the game server,
given the configuration file paths specified in the level.
Args:
level: LevelConfig which contains the paths to the env config, layout and item info files.
Returns: EnvironmentData which contains information about the newly created environment.
Raises: ValueError if the gameserver returned a conflict, HTTPError with 500 if the game server crashes.
"""
item_info_path = expand_path(level["item_info_path"])
layout_path = expand_path(level["layout_path"])
config_path = expand_path(level["config_path"])
with open(item_info_path, "r") as file:
item_info = file.read()
self.current_item_info: EnvironmentConfig = yaml.load(
item_info, Loader=yaml.Loader
)
with open(layout_path, "r") as file:
layout = file.read()
with open(config_path, "r") as file:
......@@ -173,10 +187,13 @@ class StudyState:
return env_info
def start_level(self):
level = self.levels[self.current_level_idx]
self.current_running_env = self.create_env(level)
"""Starts an environment based on the current level index."""
self.current_running_env = self.create_env(self.levels[self.current_level_idx])
def next_level(self):
"""Stops the last environment, starts the next one and
remaps the participants to the new player infos.
"""
requests.post(
f"{study_manager.game_server_url}/manage/stop_env/",
json={
......@@ -199,10 +216,16 @@ class StudyState:
}
self.participant_id_to_player_info[participant_id] = new_player_info
for key in self.players_done:
self.players_done[key] = False
for key in self.participants_done:
self.participants_done[key] = False
def add_participant(self, participant_id: str, number_players: int):
"""Adds a participant to the study, one participant can control multiple players.
Args:
participant_id: The participant id for which to register the participant.
number_players: The number of players which the participant controls.
"""
player_names = [
str(self.num_connected_players + i) for i in range(number_players)
]
......@@ -213,35 +236,51 @@ class StudyState:
self.participant_id_to_player_info[participant_id] = player_info
self.num_connected_players += number_players
def player_finished_level(self, participant_id):
self.players_done[participant_id] = True
if all(self.players_done.values()):
def participant_finished_level(self, participant_id: str):
"""Signals the server if a player has finished a level.
If all participants finished the level, the next level is started."""
self.participants_done[participant_id] = True
if all(self.participants_done.values()):
self.next_level()
def get_connection(
self, participant_id: str
) -> Tuple[PlayerInfo | None, LevelInfo | None]:
"""Get the assigned connections to the game server for a participant.
Args:
participant_id: The participant id which requests the connections.
Returns: The player info for the game server connections, level name and
information if the level is the last one and which recipes are possible in the level.
Raises: HTTPException(409) if the player is not found in the dictionary keys which saves the connections.
"""
if participant_id in self.participant_id_to_player_info.keys():
player_info = self.participant_id_to_player_info[participant_id]
current_level = self.levels[self.current_level_idx]
if self.current_config["meals"]["all"]:
recipes = ["all"]
else:
recipes = self.current_config["meals"]["list"]
level_info = LevelInfo(
name=current_level["name"],
last_level=self.last_level,
recipes=recipes,
recipe_graphs=self.current_running_env["recipe_graphs"],
)
return player_info, level_info
else:
return None, None
return player_info, level_info
raise HTTPException(
status_code=409,
detail=f"Participant not registered in this study.",
)
def create_and_connect_bot(self, player_id: str, player_info: PlayerInfo):
"""Creates and connects a bot to the current environment.
def create_and_connect_bot(self, player_id, player_info):
Args:
player_id: player id of the player the bot controls.
player_info: Connection info for the bot.
"""
player_hash = player_info["player_hash"]
ws_address = self.bot_websocket_url + player_info["client_id"]
print(
f'--general_plus="agent_websocket:{self.websocket_url + player_info["client_id"]};player_hash:{player_hash};agent_id:{player_id}"'
f'--general_plus="agent_websocket:{ws_address};player_hash:{player_hash};agent_id:{player_id}"'
)
if self.use_aaambos_agent:
sub = Popen(
......@@ -254,7 +293,7 @@ class StudyState:
str(ROOT_DIR / "configs" / "agents" / "arch_config.yml"),
"--run_config",
str(ROOT_DIR / "configs" / "agents" / "run_config.yml"),
f'--general_plus="agent_websocket:{self.websocket_url + player_info["client_id"]};player_hash:{player_hash};agent_id:{player_id}"',
f'--general_plus="agent_websocket:{ws_address};player_hash:{player_hash};agent_id:{player_id}"',
f"--instance={player_hash}",
]
),
......@@ -266,7 +305,7 @@ class StudyState:
[
"python",
str(ROOT_DIR / "configs" / "agents" / "random_agent.py"),
f'--uri {self.websocket_url + player_info["client_id"]}',
f'--uri {self.bot_websocket_url + player_info["client_id"]}',
f"--player_hash {player_hash}",
f"--player_id {player_id}",
]
......@@ -276,6 +315,7 @@ class StudyState:
self.sub_processes.append(sub)
def kill_bots(self):
"""Terminates the subprocesses of the bots."""
for sub in self.sub_processes:
try:
if self.use_aaambos_agent:
......@@ -297,26 +337,34 @@ class StudyState:
class StudyManager:
def __init__(self):
self.game_host: str | None = None
self.game_port: str | None = None
self.game_server_url: str | None = None
self.server_manager_id: str | None = None
self.running_studies: list[StudyState] = []
"""Class which manages different studies, their creation and connecting participants to them."""
self.participant_id_to_study_map: dict[str, StudyState] = {}
self.running_envs: dict[str, Tuple[int, dict[str, PlayerInfo], list[str]]] = {}
self.current_free_envs = []
def __init__(self):
self.game_host: str
"""Host address of the game server where the studies are running their environments."""
self.game_port: int
"""Port of the game server where the studies are running their environments."""
self.game_server_url: str
"""Combined URL of the game server where the studies are running their environments."""
self.server_manager_id: str
"""Manager id of this manager which will be registered in the game server."""
self.running_studies: list[Study] = []
"""List of currently running studies."""
self.participant_id_to_study_map: dict[str, Study] = {}
"""Dict which maps participants to studies."""
self.running_tutorials: dict[
str, Tuple[int, dict[str, PlayerInfo], list[str]]
] = {}
"""Dict which saves currently running tutorial envs, as these do not need advanced player management."""
self.study_config_path = ROOT_DIR / "configs" / "study" / "study_config.yml"
"""Path to the configuration file for the studies."""
def create_study(self):
study = StudyState(
"""Creates a study with the path of the config files and the connection to the game server."""
study = Study(
self.study_config_path,
self.game_host,
self.game_port,
......@@ -325,6 +373,15 @@ class StudyManager:
self.running_studies.append(study)
def add_participant(self, participant_id: str, number_players: int):
"""Adds participants to a study. Creates a new study if all other
studies have not enough free player slots
Args:
participant_id: ID of the participant which wants to connect to a study.
number_players: The number of player the participant wants to connect.
Raises: HTTPException(409) if the participants requests more players than can fit in a study.
"""
if not self.running_studies or all(
[not s.can_add_participants(number_players) for s in self.running_studies]
):
......@@ -335,36 +392,70 @@ class StudyManager:
study.add_participant(participant_id, number_players)
self.participant_id_to_study_map[participant_id] = study
return
raise HTTPException(status_code=409, detail="Could not add participant(s).")
raise HTTPException(status_code=409, detail="Too many participants to add.")
def player_finished_level(self, participant_id: str):
"""A participant signals the study manager that they finished a level.
Args:
participant_id: ID of the participant.
Raises: HTTPException(409) if this participant is not registered in any study.
"""
if participant_id in self.participant_id_to_study_map.keys():
assigned_study = self.participant_id_to_study_map[participant_id]
assigned_study.player_finished_level(participant_id)
assigned_study.participant_finished_level(participant_id)
else:
raise HTTPException(status_code=409, detail="Participant not in any study.")
def get_participant_game_connection(
self, participant_id: str
) -> Tuple[PlayerInfo, LevelInfo]:
"""Get the assigned connections to the game server for a participant.
Args:
participant_id: ID of the participant.
Returns: The player info for the game server connections, level name and
information if the level is the last one and which recipes are possible in the level.
Raises: HTTPException(409) if the player not registered in any study.
"""
if participant_id in self.participant_id_to_study_map.keys():
assigned_study = self.participant_id_to_study_map[participant_id]
player_info, level_info = assigned_study.get_connection(participant_id)
return player_info, level_info
else:
raise HTTPException(
status_code=409, detail="Participant not in this study."
)
raise HTTPException(status_code=409, detail="Participant not in any study.")
def set_game_server_url(self, game_host: str, game_port: str):
def set_game_server_url(self, game_host: str, game_port: int):
"""Set the game server host address, port and combined url. These values are set this way because
the fastapi requests act on top level of the python script.
Args:
game_host: The game server host address.
game_port: The game server port.
"""
self.game_host = game_host
self.game_port = game_port
self.game_server_url = f"http://{self.game_host}:{self.game_port}"
def set_manager_id(self, manager_id: str):
"""Set the manager id of the study server. This value is set this way because
the fastapi requests act on top level of the python script.
Args:
manager_id: Manager ID for this study manager so that it matches in the game server.
"""
self.server_manager_id = manager_id
def set_study_config(self, study_config_path: str):
"""Set the study config path of the study server. This value is set this way because
the fastapi requests act on top level of the python script.
Args:
study_config_path: Path to the study config file for the studies.
"""
# TODO validate study_config?
self.study_config_path = study_config_path
......@@ -374,12 +465,25 @@ study_manager = StudyManager()
@app.post("/start_study/{participant_id}/{number_players}")
async def start_study(participant_id: str, number_players: int):
"""Request to start a study.
Args:
participant_id: ID of the requesting participant.
number_players: Number of player the participant wants to add to a study.
"""
log.debug(f"ADDING PLAYERS: {number_players}")
study_manager.add_participant(participant_id, number_players)
@app.post("/level_done/{participant_id}")
async def level_done(participant_id: str):
"""Request to signal that a participant has finished a level.
For synchronizing level endings and starting a new level.
Args:
participant_id: ID of the requesting participant.
"""
study_manager.player_finished_level(participant_id)
......@@ -387,6 +491,14 @@ async def level_done(participant_id: str):
async def get_game_connection(
participant_id: str,
) -> dict[str, dict[str, PlayerInfo] | LevelInfo]:
"""Request to get the connection to the game server of a participant.
Args:
participant_id: ID of the requesting participant.
Returns: A dict containing the game server connection information and information about the current level.
"""
player_info, level_info = study_manager.get_participant_game_connection(
participant_id
)
......@@ -395,6 +507,15 @@ async def get_game_connection(
@app.post("/connect_to_tutorial/{participant_id}")
async def connect_to_tutorial(participant_id: str) -> JSONResponse:
"""Request of a participant to start a tutorial env and connect to it.
Args:
participant_id: ID of the requesting participant.
Returns: Player info which contains game server connection information.
Raises:
HTTPException(403) if the game server returns 403
HTTPException(500) if the game server returns 500
"""
environment_config_path = ROOT_DIR / "configs" / "tutorial_env_config.yaml"
layout_path = ROOT_DIR / "configs" / "layouts" / "tutorial.layout"
item_info_path = ROOT_DIR / "configs" / "item_info.yaml"
......@@ -432,12 +553,19 @@ async def connect_to_tutorial(participant_id: str) -> JSONResponse:
case 500:
raise HTTPException(
status_code=500,
detail=f"Game server crashed.",
detail=f"Game server crashed: {env_info.json()['detail']}",
)
@app.post("/disconnect_from_tutorial/{participant_id}")
async def disconnect_from_tutorial(participant_id: str):
"""A participant disconnects from a tutorial environment, which is then stopped on the game server.
Args:
participant_id: The participant which disconnects from the tutorial.
Raises: HTTPException(503) if the game server returns some error.
"""
answer = requests.post(
f"{study_manager.game_server_url}/manage/stop_env/",
json={
......@@ -448,7 +576,7 @@ async def disconnect_from_tutorial(participant_id: str):
)
if answer.status_code != 200:
raise HTTPException(
status_code=403, detail="Could not disconnect from tutorial"
status_code=503, detail="Could not disconnect from tutorial"
)
......@@ -470,7 +598,8 @@ if __name__ == "__main__":
parser = argparse.ArgumentParser(
prog="Cooperative Cuisine Study Server",
description="Study Server: Match Making, client pre and post managing.",
epilog="For further information, see https://scs.pages.ub.uni-bielefeld.de/cocosy/overcooked-simulator/overcooked_simulator.html",
epilog="For further information, "
"see https://scs.pages.ub.uni-bielefeld.de/cocosy/overcooked-simulator/overcooked_simulator.html",
)
url_and_port_arguments(
parser=parser,
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment