diff --git a/CHANGELOG.md b/CHANGELOG.md index fbce8a9795d9d6d58e37a19a876a1b5319985a15..c62dca6a65b6a2b0fa8c9cfcfb66f64bd68aa6b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,13 @@ - Send full websocket url in player_info. - ">90"% code coverage in tests +- i18n for the gui +- Controller hotplugging +- Hook when returning items to dispensers +- Displaying image of served meals on game conclusion screen +- Pathfinding in random agent +- Level layouts from 2d-grid-overcooked-literature +- Caching of graph recipe layouts ### Changed @@ -28,6 +35,9 @@ - Player config in the environment class is now a dataclass and not a dict. The content remains the same. Just the access changes from dict access to normal object like access. - Some type hint additions +- Better drawing of orders, now in a pygame_gui UIImage +- Buttons for setting player controls in the GUI disappear depending on number of players +- Icon for serving window, now a star ### Deprecated @@ -35,6 +45,9 @@ ### Fixed +- Orders are sampled correctly again +- Orders with no time limit, sample_on_serving=true works again + ### Security ## [1.0.0] (2024-03-08) diff --git a/README.md b/README.md index 41178079ab45f382edc3ba1c8cb9b84af288801e..a4d18ba3c632c9023e719086b710e7636be44ee3 100644 --- a/README.md +++ b/README.md @@ -20,13 +20,14 @@ the [Documentation](https://scs.pages.ub.uni-bielefeld.de/cocosy/overcooked-simu You have two options to install the environment. Either clone it and install it locally or install it in your site-packages. -You need a Python 3.10 or newer environment. Either conda or PyEnv. +You need a Python 3.10 or newer environment conda environment. ### Local Editable Installation In your `repo`, `PyCharmProjects` or similar directory with the correct environment active: ```bash +conda install -c conda-forge pygraphviz git clone https://gitlab.ub.uni-bielefeld.de/scs/cocosy/overcooked-simulator.git cd overcooked-simulator pip install -e . diff --git a/cooperative_cuisine/__init__.py b/cooperative_cuisine/__init__.py index 4ec76825b50da6103fa25529f88754827d16013a..17dfc078daff4bf21f66270c4c72fa6d2c460cbf 100644 --- a/cooperative_cuisine/__init__.py +++ b/cooperative_cuisine/__init__.py @@ -21,12 +21,14 @@ like a "real", cooperative, human partner. # Installation -You need a Python **3.10** or newer environment. +You need a Python **3.10** or newer codna environment. ```bash +conda install -c conda-forge pygraphviz pip install cooperative_cuisine@git+https://gitlab.ub.uni-bielefeld.de/scs/cocosy/overcooked-simulator@main ``` Or clone it and install it as an editable library which allows you to use all the scripts directly. ```bash +conda install -c conda-forge pygraphviz git clone https://gitlab.ub.uni-bielefeld.de/scs/cocosy/overcooked-simulator.git cd overcooked-simulator pip install -e . diff --git a/cooperative_cuisine/configs/agents/random_agent.py b/cooperative_cuisine/configs/agents/random_agent.py index 3b1b5832b32f74367582d93846681e7d9562b1b0..6d8830d94c5570bc6642936a0f34b47b94a8c8bc 100644 --- a/cooperative_cuisine/configs/agents/random_agent.py +++ b/cooperative_cuisine/configs/agents/random_agent.py @@ -7,25 +7,53 @@ import time from collections import defaultdict from datetime import datetime, timedelta +import networkx import numpy as np +import numpy.typing as npt from websockets import connect from cooperative_cuisine.action import ActionType, InterActionData, Action +from cooperative_cuisine.state_representation import ( + create_movement_graph, + astar_heuristic, + restrict_movement_graph, +) from cooperative_cuisine.utils import custom_asdict_factory TIME_TO_STOP_ACTION = 3.0 +ADD_RANDOM_MOVEMENTS = False +DIAGONAL_MOVEMENTS = True +AVOID_OTHER_PLAYERS = True + + +def get_free_neighbours( + state: dict, counter_pos: list[float] | tuple[float, float] | npt.NDArray +) -> list[tuple[float, float]]: + width, height = state["kitchen"]["width"], state["kitchen"]["height"] + free_space = np.ones((width, height), dtype=bool) + for counter in state["counters"]: + grid_idx = np.array(counter["pos"]).astype(int) + free_space[grid_idx[0], grid_idx[1]] = False + i, j = np.array(counter_pos).astype(int) + free = [] + + for x, y in [(i - 1, j), (i + 1, j), (i, j - 1), (i, j + 1)]: + if 0 < x < width and 0 < y < height and free_space[x, y]: + free.append((x, y)) + return free + async def agent(): parser = argparse.ArgumentParser("Random agent") parser.add_argument("--uri", type=str) parser.add_argument("--player_id", type=str) parser.add_argument("--player_hash", type=str) - parser.add_argument("--step_time", type=float, default=0.5) + parser.add_argument("--step_time", type=float, default=0.1) args = parser.parse_args() - async with connect(args.uri) as websocket: + async with (connect(args.uri) as websocket): await websocket.send( json.dumps({"type": "ready", "player_hash": args.player_hash}) ) @@ -34,6 +62,9 @@ async def agent(): ended = False counters = None + all_counters = None + + movement_graph = None player_info = {} current_agent_pos = None @@ -60,10 +91,16 @@ async def agent(): if not state["all_players_ready"]: continue + if movement_graph is None: + movement_graph = create_movement_graph( + state, diagonal=DIAGONAL_MOVEMENTS + ) + if counters is None: counters = defaultdict(list) for counter in state["counters"]: counters[counter["type"]].append(counter) + all_counters = state["counters"] for player in state["players"]: if player["id"] == args.player_id: @@ -125,10 +162,77 @@ async def agent(): task_type = None match task_type: case "GOTO": - diff = np.array(task_args) - np.array(current_agent_pos) - dist = np.linalg.norm(diff) - if dist > 1.2: - if dist != 0: + target_diff = np.array(task_args) - np.array(current_agent_pos) + target_dist = np.linalg.norm(target_diff) + + source = tuple( + np.round(np.array(current_agent_pos)).astype(int) + ) + target = tuple(np.array(task_args).astype(int)) + target_free_spaces = get_free_neighbours(state, target) + paths = [] + for free in target_free_spaces: + try: + path = networkx.astar_path( + restrict_movement_graph( + graph=movement_graph, + player_positions=[ + p["pos"] + for p in state["players"] + if p["id"] != args.player_id + ], + ) + if AVOID_OTHER_PLAYERS + else movement_graph, + source, + free, + heuristic=astar_heuristic, + ) + paths.append(path) + except networkx.exception.NetworkXNoPath: + pass + except networkx.exception.NodeNotFound: + pass + + if paths: + shortest_path = paths[np.argmin([len(p) for p in paths])] + if len(shortest_path) > 1: + node_diff = shortest_path[1] - np.array( + current_agent_pos + ) + node_dist = np.linalg.norm(node_diff) + movement = node_diff / node_dist + else: + movement = target_diff / target_dist + do_movement = True + else: + # no paths available + print("NO PATHS") + + # task_type = None + # task_args = None + do_movement = False + + if target_dist > 1.2 and do_movement: + if target_dist != 0: + if ADD_RANDOM_MOVEMENTS: + random_small_rotation_angle = ( + np.random.random() * np.pi * 0.1 + ) + rotation_matrix = np.array( + [ + [ + np.cos(random_small_rotation_angle), + -np.sin(random_small_rotation_angle), + ], + [ + np.sin(random_small_rotation_angle), + np.cos(random_small_rotation_angle), + ], + ] + ) + movement = rotation_matrix @ movement + await websocket.send( json.dumps( { @@ -137,7 +241,7 @@ async def agent(): Action( args.player_id, ActionType.MOVEMENT, - (diff / dist).tolist(), + movement.tolist(), args.step_time + 0.01, ), dict_factory=custom_asdict_factory, @@ -148,6 +252,8 @@ async def agent(): ) await websocket.recv() else: + # Target reached here. + print("TARGET REACHED") task_type = None task_args = None case "INTERACT": @@ -207,9 +313,19 @@ async def agent(): task_type = random.choice(["GOTO", "PUT", "INTERACT"]) threshold = datetime.now() + timedelta(seconds=TIME_TO_STOP_ACTION) if task_type == "GOTO": - counter_type = random.choice(list(counters.keys())) - task_args = random.choice(counters[counter_type])["pos"] - print(args.player_hash, args.player_id, task_type, counter_type) + # counter_type = random.choice(list(counters.keys())) + # task_args = random.choice(counters[counter_type])["pos"] + + random_counter = random.choice(all_counters) + counter_type = random_counter["type"] + task_args = random_counter["pos"] + print( + args.player_hash, + args.player_id, + task_type, + counter_type, + task_args, + ) else: print(args.player_hash, args.player_id, task_type) task_args = None diff --git a/cooperative_cuisine/configs/environment_config.yaml b/cooperative_cuisine/configs/environment_config.yaml index b005d07a014b62adac291f8716d42a709f789fac..d6a51ea95225f77ae00cb56e49a2d4eda40c95e2 100644 --- a/cooperative_cuisine/configs/environment_config.yaml +++ b/cooperative_cuisine/configs/environment_config.yaml @@ -65,8 +65,8 @@ orders: # 'random' library call with getattr, kwargs are passed to the function func: uniform kwargs: - a: 40 - b: 60 + a: 55 + b: 65 max_orders: 6 # maximum number of active orders at the same time num_start_meals: 2 @@ -75,17 +75,18 @@ orders: # 'random' library call with getattr, kwargs are passed to the function func: uniform kwargs: - a: 10 - b: 20 + a: 35 + b: 45 sample_on_serving: false # Sample the delay for the next order only after a meal was served. serving_not_ordered_meals: true # can meals that are not ordered be served / dropped on the serving window + player_config: radius: 0.4 speed_units_per_seconds: 6 - interaction_range: 1.6 + interaction_range: 1.25 restricted_view: False view_angle: 70 view_range: 4 # in grid units, can be "null" diff --git a/cooperative_cuisine/configs/environment_config_no_validation.yaml b/cooperative_cuisine/configs/environment_config_no_validation.yaml new file mode 100644 index 0000000000000000000000000000000000000000..1802c28a539cd92d7879ea14643f545bcebd3c99 --- /dev/null +++ b/cooperative_cuisine/configs/environment_config_no_validation.yaml @@ -0,0 +1,208 @@ +plates: + clean_plates: 2 + dirty_plates: 1 + plate_delay: [ 5, 10 ] + # range of seconds until the dirty plate arrives. + +game: + time_limit_seconds: 300 + undo_dispenser_pickup: true + validate_recipes: false + + +layout_chars: + _: Free + hash: Counter # # + A: Agent + pipe: Extinguisher + P: PlateDispenser + C: CuttingBoard + X: Trashcan + $: ServingWindow + S: Sink + +: SinkAddon + at: Plate # @ just a clean plate on a counter + U: Pot # with Stove + Q: Pan # with Stove + O: Peel # with Oven + F: Basket # with DeepFryer + T: Tomato + N: Onion # oNioN + L: Lettuce + K: Potato # Kartoffel + I: Fish # fIIIsh + D: Dough + E: Cheese # chEEEse + G: Sausage # sausaGe + B: Bun + M: Meat + question: Counter # ? mushroom + ↓: Counter + ^: Counter + right: Counter + left: Counter + wave: Free # ~ Water + minus: Free # - Ice + dquote: Counter # " wall/truck + p: Counter # second plate return ?? + + +orders: + meals: + all: true + # if all: false -> only orders for these meals are generated + # TODO: what if this list is empty? + list: + # - TomatoSoup + # - OnionSoup + # - Salad + - FriedFish + order_gen_class: !!python/name:cooperative_cuisine.orders.RandomOrderGeneration '' + # the class to that receives the kwargs. Should be a child class of OrderGeneration in orders.py + order_gen_kwargs: + order_duration_random_func: + # how long should the orders be alive + # 'random' library call with getattr, kwargs are passed to the function + func: uniform + kwargs: + a: 40 + b: 60 + max_orders: 6 + # maximum number of active orders at the same time + num_start_meals: 2 + # number of orders generated at the start of the environment + sample_on_dur_random_func: + # 'random' library call with getattr, kwargs are passed to the function + func: uniform + kwargs: + a: 10 + b: 20 + sample_on_serving: false + # Sample the delay for the next order only after a meal was served. + serving_not_ordered_meals: true + # can meals that are not ordered be served / dropped on the serving window + +player_config: + radius: 0.4 + speed_units_per_seconds: 6 + interaction_range: 1.6 + restricted_view: False + view_angle: 70 + view_range: 4 # in grid units, can be "null" + +effect_manager: + FireManager: + class: !!python/name:cooperative_cuisine.effects.FireEffectManager '' + kwargs: + spreading_duration: [ 5, 10 ] + fire_burns_ingredients_and_meals: true + + +hook_callbacks: + # # --------------- Scoring --------------- + orders: + hooks: [ completed_order ] + callback_class: !!python/name:cooperative_cuisine.scores.ScoreViaHooks '' + callback_class_kwargs: + static_score: 20 + score_on_specific_kwarg: meal_name + score_map: + Burger: 15 + OnionSoup: 10 + Salad: 5 + TomatoSoup: 10 + not_ordered_meals: + hooks: [ serve_not_ordered_meal ] + callback_class: !!python/name:cooperative_cuisine.scores.ScoreViaHooks '' + callback_class_kwargs: + static_score: 2 + trashcan_usages: + hooks: [ trashcan_usage ] + callback_class: !!python/name:cooperative_cuisine.scores.ScoreViaHooks '' + callback_class_kwargs: + static_score: -5 + expired_orders: + hooks: [ order_expired ] + callback_class: !!python/name:cooperative_cuisine.scores.ScoreViaHooks '' + callback_class_kwargs: + static_score: -10 + # --------------- Recording --------------- + # json_states: + # hooks: [ json_state ] + # callback_class: !!python/name:cooperative_cuisine.recording.FileRecorder '' + # callback_class_kwargs: + # record_path: USER_LOG_DIR/ENV_NAME/json_states.jsonl + actions: + hooks: [ pre_perform_action ] + callback_class: !!python/name:cooperative_cuisine.recording.FileRecorder '' + callback_class_kwargs: + record_path: USER_LOG_DIR/ENV_NAME/LOG_RECORD_NAME.jsonl + random_env_events: + hooks: [ order_duration_sample, plate_out_of_kitchen_time ] + callback_class: !!python/name:cooperative_cuisine.recording.FileRecorder '' + callback_class_kwargs: + record_path: USER_LOG_DIR/ENV_NAME/LOG_RECORD_NAME.jsonl + add_hook_ref: true + env_configs: + hooks: [ env_initialized, item_info_config ] + callback_class: !!python/name:cooperative_cuisine.recording.FileRecorder '' + callback_class_kwargs: + record_path: USER_LOG_DIR/ENV_NAME/LOG_RECORD_NAME.jsonl + add_hook_ref: true + + # Game event recording + game_events: + hooks: + - post_counter_pick_up + - post_counter_drop_off + - post_dispenser_pick_up + - cutting_board_100 + - player_start_interaction + - player_end_interact + - post_serving + - no_serving + - dirty_plate_arrives + - trashcan_usage + - plate_cleaned + - added_plate_to_sink + - drop_on_sink_addon + - pick_up_from_sink_addon + - serve_not_ordered_meal + - serve_without_plate + - completed_order + - new_orders + - order_expired + - action_on_not_reachable_counter + - new_fire + - fire_spreading + - drop_off_on_cooking_equipment + - players_collide + - post_plate_dispenser_pick_up + - post_plate_dispenser_drop_off + - on_item_transition + - progress_started + - progress_finished + - content_ready + - dispenser_item_returned + + callback_class: !!python/name:cooperative_cuisine.recording.FileRecorder '' + callback_class_kwargs: + record_path: USER_LOG_DIR/ENV_NAME/LOG_RECORD_NAME.jsonl + add_hook_ref: true + + +# info_msg: +# func: !!python/name:cooperative_cuisine.hooks.hooks_via_callback_class '' +# kwargs: +# hooks: [ cutting_board_100 ] +# callback_class: !!python/name:cooperative_cuisine.info_msg.InfoMsgManager '' +# callback_class_kwargs: +# msg: Glückwunsch du hast was geschnitten! +# fire_msg: +# func: !!python/name:cooperative_cuisine.hooks.hooks_via_callback_class '' +# kwargs: +# hooks: [ new_fire ] +# callback_class: !!python/name:cooperative_cuisine.info_msg.InfoMsgManager '' +# callback_class_kwargs: +# msg: Feuer, Feuer, Feuer +# level: Warning diff --git a/cooperative_cuisine/configs/item_info_overcooked-ai.yaml b/cooperative_cuisine/configs/item_info_overcooked-ai.yaml new file mode 100644 index 0000000000000000000000000000000000000000..ade54dfbf7821178cb447cc274b4285b4d3083e8 --- /dev/null +++ b/cooperative_cuisine/configs/item_info_overcooked-ai.yaml @@ -0,0 +1,233 @@ +CuttingBoard: + type: Equipment + +Sink: + type: Equipment + +Stove: + type: Equipment + +DeepFryer: + type: Equipment + +Oven: + type: Equipment + +Pot: + type: Equipment + equipment: Stove + +Pan: + type: Equipment + equipment: Stove + +Basket: + type: Equipment + equipment: DeepFryer + +Peel: + type: Equipment + equipment: Oven + +DirtyPlate: + type: Equipment + +Plate: + type: Equipment + needs: [ DirtyPlate ] + seconds: 2.0 + equipment: Sink + +# -------------------------------------------------------------------------------- + +Tomato: + type: Ingredient + +Lettuce: + type: Ingredient + +Onion: + type: Ingredient + +Meat: + type: Ingredient + +Bun: + type: Ingredient + +Potato: + type: Ingredient + +Fish: + type: Ingredient + +Dough: + type: Ingredient + +Cheese: + type: Ingredient + +Sausage: + type: Ingredient + +# Chopped things +ChoppedTomato: + type: Ingredient + needs: [ Tomato ] + seconds: 4.0 + equipment: CuttingBoard + +ChoppedLettuce: + type: Ingredient + needs: [ Lettuce ] + seconds: 3.0 + equipment: CuttingBoard + +ChoppedOnion: + type: Ingredient + needs: [ Onion ] + seconds: 4.0 + equipment: CuttingBoard + +RawPatty: + type: Ingredient + needs: [ Meat ] + seconds: 4.0 + equipment: CuttingBoard + +RawChips: + type: Ingredient + needs: [ Potato ] + seconds: 4.0 + equipment: CuttingBoard + +ChoppedFish: + type: Ingredient + needs: [ Fish ] + seconds: 4.0 + equipment: CuttingBoard + +PizzaBase: + type: Ingredient + needs: [ Dough ] + seconds: 4.0 + equipment: CuttingBoard + +GratedCheese: + type: Ingredient + needs: [ Cheese ] + seconds: 4.0 + equipment: CuttingBoard + +ChoppedSausage: + type: Ingredient + needs: [ Sausage ] + seconds: 4.0 + equipment: CuttingBoard + +CookedPatty: + type: Ingredient + seconds: 5.0 + needs: [ RawPatty ] + equipment: Pan + +# -------------------------------------------------------------------------------- + +Chips: + type: Meal + seconds: 5.0 + needs: [ RawChips ] + equipment: Basket + +FriedFish: + type: Meal + seconds: 5.0 + needs: [ ChoppedFish ] + equipment: Basket + +Burger: + type: Meal + needs: [ Bun, ChoppedLettuce, ChoppedTomato, CookedPatty ] + equipment: ~ + +Salad: + type: Meal + needs: [ ChoppedLettuce, ChoppedTomato ] + equipment: ~ + +TomatoSoup: + type: Meal + needs: [ ChoppedTomato, ChoppedTomato, ChoppedTomato ] + seconds: 6.0 + equipment: Pot + +OnionSoup: + type: Meal + needs: [ Onion, Onion, Onion ] + seconds: 6.0 + equipment: Pot + +FishAndChips: + type: Meal + needs: [ FriedFish, Chips ] + equipment: ~ + +Pizza: + type: Meal + needs: [ PizzaBase, ChoppedTomato, GratedCheese, ChoppedSausage ] + seconds: 7.0 + equipment: Peel + +# -------------------------------------------------------------------------------- + +BurntCookedPatty: + type: Waste + seconds: 10.0 + needs: [ CookedPatty ] + equipment: Pan + +BurntChips: + type: Waste + seconds: 10.0 + needs: [ Chips ] + equipment: Basket + +BurntFriedFish: + type: Waste + seconds: 10.0 + needs: [ FriedFish ] + equipment: Basket + +BurntTomatoSoup: + type: Waste + needs: [ TomatoSoup ] + seconds: 20.0 + equipment: Pot + +BurntOnionSoup: + type: Waste + needs: [ OnionSoup ] + seconds: 20.0 + equipment: Pot + +BurntPizza: + type: Waste + needs: [ Pizza ] + seconds: 10.0 + equipment: Peel + +# -------------------------------------------------------------------------------- + +Fire: + type: Effect + seconds: 20.0 + needs: [ BurntCookedPatty, BurntChips, BurntFriedFish, BurntTomatoSoup, BurntOnionSoup, BurntPizza ] + manager: FireManager + effect_type: Unusable + +# -------------------------------------------------------------------------------- + +Extinguisher: + type: Tool + seconds: 1.0 + needs: [ Fire ] diff --git a/cooperative_cuisine/configs/layouts/gym-cooking/1-open-divider.layout b/cooperative_cuisine/configs/layouts/gym-cooking/1-open-divider.layout new file mode 100644 index 0000000000000000000000000000000000000000..b21b3150931f3357b7441619f0362eab71ee1f2e --- /dev/null +++ b/cooperative_cuisine/configs/layouts/gym-cooking/1-open-divider.layout @@ -0,0 +1,7 @@ +#####T# +C_____L +C_____# +$A___A# +#_____# +#_____P +#####P# \ No newline at end of file diff --git a/cooperative_cuisine/configs/layouts/gym-cooking/2-partial-divider.layout b/cooperative_cuisine/configs/layouts/gym-cooking/2-partial-divider.layout new file mode 100644 index 0000000000000000000000000000000000000000..5d9d0d66be8e354eb79a84a491470bdae2be0da7 --- /dev/null +++ b/cooperative_cuisine/configs/layouts/gym-cooking/2-partial-divider.layout @@ -0,0 +1,7 @@ +#####T# +C__#__L +C__#__# +$A_#_A# +#__#__# +#_____P +#####P# \ No newline at end of file diff --git a/cooperative_cuisine/configs/layouts/gym-cooking/3-full-divider.layout b/cooperative_cuisine/configs/layouts/gym-cooking/3-full-divider.layout new file mode 100644 index 0000000000000000000000000000000000000000..9bd6b29a7bfc7dac73137b43a4f6eae6b2ae5054 --- /dev/null +++ b/cooperative_cuisine/configs/layouts/gym-cooking/3-full-divider.layout @@ -0,0 +1,7 @@ +#####T# +C__#__L +C__#__# +$A_#_A# +#__#__# +#__#__P +#####P# \ No newline at end of file diff --git a/cooperative_cuisine/configs/layouts_archive/test_layouts/large.layout b/cooperative_cuisine/configs/layouts/large.layout similarity index 100% rename from cooperative_cuisine/configs/layouts_archive/test_layouts/large.layout rename to cooperative_cuisine/configs/layouts/large.layout diff --git a/cooperative_cuisine/configs/layouts_archive/test_layouts/large_t.layout b/cooperative_cuisine/configs/layouts/large_t.layout similarity index 100% rename from cooperative_cuisine/configs/layouts_archive/test_layouts/large_t.layout rename to cooperative_cuisine/configs/layouts/large_t.layout diff --git a/cooperative_cuisine/configs/layouts/overcooked-ai/1-cramped-room.layout b/cooperative_cuisine/configs/layouts/overcooked-ai/1-cramped-room.layout new file mode 100644 index 0000000000000000000000000000000000000000..49655d1ce3c4217a1fd2385cd5b33918fd395137 --- /dev/null +++ b/cooperative_cuisine/configs/layouts/overcooked-ai/1-cramped-room.layout @@ -0,0 +1,4 @@ +##U## +NA_AN +#___# +#P#$# \ No newline at end of file diff --git a/cooperative_cuisine/configs/layouts/overcooked-ai/2-asymmetric-advantages.layout b/cooperative_cuisine/configs/layouts/overcooked-ai/2-asymmetric-advantages.layout new file mode 100644 index 0000000000000000000000000000000000000000..3b87f3e58caaa5a5c59f732889986704ce2dce8f --- /dev/null +++ b/cooperative_cuisine/configs/layouts/overcooked-ai/2-asymmetric-advantages.layout @@ -0,0 +1,5 @@ +######### +N_#$#N#_$ +#_A_U_A_# +#___U___# +###P#P### \ No newline at end of file diff --git a/cooperative_cuisine/configs/layouts/overcooked-ai/3-coordination-ring.layout b/cooperative_cuisine/configs/layouts/overcooked-ai/3-coordination-ring.layout new file mode 100644 index 0000000000000000000000000000000000000000..f83c2adf9045825e86de62ae3626e3325cbb0a7f --- /dev/null +++ b/cooperative_cuisine/configs/layouts/overcooked-ai/3-coordination-ring.layout @@ -0,0 +1,5 @@ +###U# +#__AU +P_#_# +NA__# +#N$## diff --git a/cooperative_cuisine/configs/layouts/overcooked-ai/4-forced-coordination.layout b/cooperative_cuisine/configs/layouts/overcooked-ai/4-forced-coordination.layout new file mode 100644 index 0000000000000000000000000000000000000000..bc835ae37db299eb0cf7c61f7c5de85478becb99 --- /dev/null +++ b/cooperative_cuisine/configs/layouts/overcooked-ai/4-forced-coordination.layout @@ -0,0 +1,5 @@ +###U# +N_#AU +N_#_# +PA#_# +###$# diff --git a/cooperative_cuisine/configs/layouts/overcooked-ai/5-counter-circuit.layout b/cooperative_cuisine/configs/layouts/overcooked-ai/5-counter-circuit.layout new file mode 100644 index 0000000000000000000000000000000000000000..6c5839500ace8c8314b87b17dde31c257c994243 --- /dev/null +++ b/cooperative_cuisine/configs/layouts/overcooked-ai/5-counter-circuit.layout @@ -0,0 +1,5 @@ +###UU### +#A_____# +P_####_$ +#_____A# +###NN### \ No newline at end of file diff --git a/cooperative_cuisine/configs/study/gym-cooking_study.yaml b/cooperative_cuisine/configs/study/gym-cooking_study.yaml new file mode 100644 index 0000000000000000000000000000000000000000..e33204735f77732a7f28d6bc1fb891c6ce74a5d0 --- /dev/null +++ b/cooperative_cuisine/configs/study/gym-cooking_study.yaml @@ -0,0 +1,43 @@ +levels: + - config_path: CONFIGS_DIR/environment_config.yaml + layout_path: LAYOUTS_DIR/gym-cooking/1-open-divider.layout + item_info_path: CONFIGS_DIR/item_info.yaml + name: "Level 1: Open Divider" + config_overwrite: + game: + time_limit_seconds: 10 + plates: + clean_plates: 3 + dirty_plates: 0 + return_dirty: false + + - config_path: CONFIGS_DIR/environment_config.yaml + layout_path: LAYOUTS_DIR/gym-cooking/2-partial-divider.layout + item_info_path: CONFIGS_DIR/item_info.yaml + name: "Level 2: Partial Divider" + config_overwrite: + game: + time_limit_seconds: 10 + plates: + clean_plates: 3 + dirty_plates: 0 + return_dirty: false + + + - config_path: CONFIGS_DIR/environment_config.yaml + layout_path: LAYOUTS_DIR/gym-cooking/3-full-divider.layout + item_info_path: CONFIGS_DIR/item_info.yaml + name: "Level 3: Full Divider" + config_overwrite: + game: + time_limit_seconds: 10 + plates: + clean_plates: 3 + dirty_plates: 0 + return_dirty: false + + + + +num_players: 1 +num_bots: 0 diff --git a/cooperative_cuisine/configs/study/overcooked-ai_study.yaml b/cooperative_cuisine/configs/study/overcooked-ai_study.yaml new file mode 100644 index 0000000000000000000000000000000000000000..b5cd79f6a2950f35bf88386dc3c5dc9a138517bc --- /dev/null +++ b/cooperative_cuisine/configs/study/overcooked-ai_study.yaml @@ -0,0 +1,65 @@ +levels: + - config_path: CONFIGS_DIR/environment_config.yaml + layout_path: LAYOUTS_DIR/overcooked-ai/1-cramped-room.layout + item_info_path: CONFIGS_DIR/item_info_overcooked-ai.yaml + name: "Level 1: Cramped Room" + config_overwrite: + game: + time_limit_seconds: 120 + plates: + clean_plates: 3 + dirty_plates: 0 + return_dirty: false + + - config_path: CONFIGS_DIR/environment_config.yaml + layout_path: LAYOUTS_DIR/overcooked-ai/2-asymmetric-advantages.layout + item_info_path: CONFIGS_DIR/item_info_overcooked-ai.yaml + name: "Level 2: Asymmetric Advantages" + config_overwrite: + game: + time_limit_seconds: 120 + plates: + clean_plates: 3 + dirty_plates: 0 + return_dirty: false + + - config_path: CONFIGS_DIR/environment_config.yaml + layout_path: LAYOUTS_DIR/overcooked-ai/3-coordination-ring.layout + item_info_path: CONFIGS_DIR/item_info_overcooked-ai.yaml + name: "Level 3: Coordination Ring" + config_overwrite: + game: + time_limit_seconds: 120 + plates: + clean_plates: 3 + dirty_plates: 0 + return_dirty: false + + - config_path: CONFIGS_DIR/environment_config.yaml + layout_path: LAYOUTS_DIR/overcooked-ai/4-forced-coordination.layout + item_info_path: CONFIGS_DIR/item_info_overcooked-ai.yaml + name: "Level 4: Forced Coordination" + config_overwrite: + game: + time_limit_seconds: 120 + plates: + clean_plates: 3 + dirty_plates: 0 + return_dirty: false + + - config_path: CONFIGS_DIR/environment_config.yaml + layout_path: LAYOUTS_DIR/overcooked-ai/5-counter-circuit.layout + item_info_path: CONFIGS_DIR/item_info_overcooked-ai.yaml + name: "Level 5: Counter Circuit" + config_overwrite: + game: + time_limit_seconds: 120 + plates: + clean_plates: 3 + dirty_plates: 0 + return_dirty: false + + + +num_players: 1 +num_bots: 0 diff --git a/cooperative_cuisine/configs/study/study_config.yaml b/cooperative_cuisine/configs/study/study_config.yaml index 8318e39610e0f984fe1070a9cea039e56ef09d05..0c2ef412c98b6497e7b70d3c355157557b928ddf 100644 --- a/cooperative_cuisine/configs/study/study_config.yaml +++ b/cooperative_cuisine/configs/study/study_config.yaml @@ -1,35 +1,52 @@ levels: + - config_path: CONFIGS_DIR/environment_config.yaml layout_path: LAYOUTS_DIR/overcooked-1/1-1-far-apart.layout item_info_path: CONFIGS_DIR/item_info.yaml - name: "Level 1-1: Far Apart" + name: "Level 1" config_overwrite: game: time_limit_seconds: 300 plates: clean_plates: 0 dirty_plates: 0 + orders: + order_gen_kwargs: + order_duration_random_func: + kwargs: + a: 60 + b: 70 - config_path: CONFIGS_DIR/environment_config.yaml - layout_path: LAYOUTS_DIR/basic.layout + layout_path: LAYOUTS_DIR/overcooked-1/1-4-bottleneck.layout item_info_path: CONFIGS_DIR/item_info.yaml - name: "Basic" + name: "Level 2" config_overwrite: game: time_limit_seconds: 300 - config_path: CONFIGS_DIR/environment_config.yaml - layout_path: LAYOUTS_DIR/overcooked-1/1-4-bottleneck.layout + layout_path: LAYOUTS_DIR/overcooked-1/1-5-circle.layout item_info_path: CONFIGS_DIR/item_info.yaml - name: "Level 1-4: Bottleneck" + name: "Level 3" config_overwrite: - player_config: - restricted_view: true + game: + time_limit_seconds: 300 plates: - clean_plates: 0 + clean_plates: 1 dirty_plates: 0 + return_dirty: false + + - config_path: CONFIGS_DIR/environment_config.yaml + layout_path: LAYOUTS_DIR/overcooked-1/4-1-moving-counters.layout + item_info_path: CONFIGS_DIR/item_info.yaml + name: "Level 4" + config_overwrite: game: time_limit_seconds: 300 + plates: + clean_plates: 0 + dirty_plates: 0 diff --git a/cooperative_cuisine/counter_factory.py b/cooperative_cuisine/counter_factory.py index 5232a5bf70e7df880ea7bba28e1257c5275aa130..b1aae2f6f647daa6a47eda343ab5eb56a1d44d35 100644 --- a/cooperative_cuisine/counter_factory.py +++ b/cooperative_cuisine/counter_factory.py @@ -197,8 +197,6 @@ class CounterFactory: assert self.can_map(c), f"Can't map counter char {c}" counter_class = None - # if c == "@": - # print("-") if self.layout_chars_config[c] in self.item_info: item_info = self.item_info[self.layout_chars_config[c]] if item_info.type == ItemType.Equipment and item_info.equipment: @@ -548,7 +546,6 @@ def determine_counter_orientations( [np.linalg.norm(vector_to_center - n) for n in neighbours_free] ) nearest_vec = neighbours_free[n_idx] - # print(nearest_vec, type(nearest_vec)) c.set_orientation(nearest_vec) elif grid_idx[0] == 0: diff --git a/cooperative_cuisine/game_server.py b/cooperative_cuisine/game_server.py index c017a1d0a38dcebb25e51b63286eb18aadcd60b9..01a3b02cbecd8cf515d929bca83152bc81e71cc1 100644 --- a/cooperative_cuisine/game_server.py +++ b/cooperative_cuisine/game_server.py @@ -41,6 +41,7 @@ from cooperative_cuisine.utils import ( add_list_of_manager_ids_arguments, disable_websocket_logging_arguments, setup_logging, + UUID_CUTOFF, ) log = logging.getLogger(__name__) @@ -162,8 +163,7 @@ class EnvironmentHandler: """ if environment_config.manager_id not in self.allowed_manager: return 1 - env_id = uuid.uuid4().hex - + env_id = f"{environment_config.env_name}_env_{uuid.uuid4().hex[:UUID_CUTOFF]}" # todo uuid cutoff if environment_config.number_players < 1: raise HTTPException( status_code=409, detail="Number players need to be positive." @@ -749,6 +749,7 @@ class CreateEnvironmentConfig(BaseModel): environment_config: str # file content layout_config: str # file content seed: int + env_name: str class ManageEnv(BaseModel): diff --git a/cooperative_cuisine/movement.py b/cooperative_cuisine/movement.py index b0c9d50a034d83f82f0736d2213bdb71e4d6c11d..2523e50f871758f6e9e3049a2b8ca60f0d59e042 100644 --- a/cooperative_cuisine/movement.py +++ b/cooperative_cuisine/movement.py @@ -193,32 +193,37 @@ class Movement: updated_movement * (self.player_movement_speed * d_time) ) - # Check collisions with counters + # check if players collided with counters through movement or through being pushed ( collided, relevant_axes, nearest_counter_to_player, ) = self.get_counter_collisions(new_targeted_positions) - # Check if sliding against counters is possible - for idx, player in enumerate(player_positions): - axis = relevant_axes[idx] - if collided[idx]: - # collide with counter left or top - if nearest_counter_to_player[idx][axis] > 0: - updated_movement[idx, axis] = np.max( - [updated_movement[idx, axis], 0] - ) - # collide with counter right or bottom - if nearest_counter_to_player[idx][axis] < 0: - updated_movement[idx, axis] = np.min( - [updated_movement[idx, axis], 0] - ) - new_positions = player_positions + ( - updated_movement * (self.player_movement_speed * d_time) + # If collided, check if the players could still move along the axis, starting with x + # This leads to players beeing able to slide along counters, which feels alot nicer. + projected_x = updated_movement.copy() + projected_x[collided, 1] = 0 + new_targeted_positions[collided] = player_positions[collided] + ( + projected_x[collided] * (self.player_movement_speed * d_time) + ) + # checking collisions again + ( + collided, + relevant_axes, + nearest_counter_to_player, + ) = self.get_counter_collisions(new_targeted_positions) + new_targeted_positions[collided] = player_positions[collided] + # and now y axis collisions + projected_y = updated_movement.copy() + projected_y[collided, 0] = 0 + new_targeted_positions[collided] = player_positions[collided] + ( + projected_y[collided] * (self.player_movement_speed * d_time) ) + new_positions = new_targeted_positions - # Check collisions with counters again, now absolute with no sliding possible + # Check collisions with counters a final time, now absolute with no sliding possible. + # Players should never be able to enter counters this way. ( collided, relevant_axes, @@ -226,7 +231,7 @@ class Movement: ) = self.get_counter_collisions(new_positions) new_positions[collided] = player_positions[collided] - # Collisions player world borders + # Collisions of players with world borders new_positions = np.clip( new_positions, self.world_borders_lower + self.player_radius, diff --git a/cooperative_cuisine/orders.py b/cooperative_cuisine/orders.py index 58db96b624eacf9b52a52bc36eb6bc6cdb998b7a..33c841f71c12a69fec1c8c656bed01c3ce7a30cf 100644 --- a/cooperative_cuisine/orders.py +++ b/cooperative_cuisine/orders.py @@ -251,6 +251,8 @@ class OrderManager: self.hook(INIT_ORDERS) self.open_orders.extend(init_orders) + # self.update_next_relevant_time() + def progress(self, passed_time: timedelta, now: datetime): """Check expired orders and check order generation.""" new_orders = self.order_gen.get_orders( @@ -434,6 +436,14 @@ class RandomOrderGeneration(OrderGeneration): if new_finished_orders: self.create_random_next_time_delta(now) return [] + # print( + # " - -", + # self.needed_orders, + # self.number_cur_orders, + # self.next_order_time, + # now, + # ) + if self.needed_orders: self.needed_orders -= len(new_finished_orders) self.needed_orders = max(self.needed_orders, 0) @@ -442,6 +452,7 @@ class RandomOrderGeneration(OrderGeneration): self.random.choices(self.available_meals, k=len(new_finished_orders)), now, ) + if self.next_order_time <= now: if self.number_cur_orders >= self.kwargs.max_orders: self.needed_orders += 1 @@ -473,7 +484,7 @@ class RandomOrderGeneration(OrderGeneration): orders = [] for meal in meals: if no_time_limit: - duration = datetime.max - now + duration = timedelta(days=365) else: if isinstance(self.kwargs.order_duration_random_func["func"], str): seconds = getattr( @@ -502,11 +513,11 @@ class RandomOrderGeneration(OrderGeneration): def create_random_next_time_delta(self, now: datetime): if isinstance(self.kwargs.order_duration_random_func["func"], str): seconds = getattr( - self.random, self.kwargs.order_duration_random_func["func"] - )(**self.kwargs.order_duration_random_func["kwargs"]) + self.random, self.kwargs.sample_on_dur_random_func["func"] + )(**self.kwargs.sample_on_dur_random_func["kwargs"]) else: - seconds = self.kwargs.order_duration_random_func["func"]( - **self.kwargs.order_duration_random_func["kwargs"] + seconds = self.kwargs.sample_on_dur_random_func["func"]( + **self.kwargs.sample_on_dur_random_func["kwargs"] ) self.next_order_time = now + timedelta(seconds=seconds) diff --git a/cooperative_cuisine/player.py b/cooperative_cuisine/player.py index 4da8e8b1e3e757616a1c535f71c5cc5ad3fd510c..81226bfceadd3782a207d5f2de3e59c573f5155f 100644 --- a/cooperative_cuisine/player.py +++ b/cooperative_cuisine/player.py @@ -133,9 +133,7 @@ class Player: def update_facing_point(self): """Update facing point on the player border circle based on the radius.""" - self.facing_point = self.pos + ( - self.facing_direction * self.player_config.radius * 0.5 - ) + self.facing_point = self.pos + (self.facing_direction * 0.62) def can_reach(self, counter: Counter) -> bool: """Checks whether the player can reach the counter in question. Simple check if the distance is not larger diff --git a/cooperative_cuisine/pygame_2d_vis/drawing.py b/cooperative_cuisine/pygame_2d_vis/drawing.py index 1207752c1c6c246c4574f0049435337c37dfe81f..899f0559d2e4da9cdd53fdc5ba6725f6f19ee36a 100644 --- a/cooperative_cuisine/pygame_2d_vis/drawing.py +++ b/cooperative_cuisine/pygame_2d_vis/drawing.py @@ -149,7 +149,7 @@ class Visualizer: grid_size, ) - for idx, col in zip(controlled_player_idxs, [colors["blue"], colors["red"]]): + for idx, col in zip(controlled_player_idxs, [colors["red"], colors["blue"]]): pygame.draw.circle( screen, col, @@ -537,7 +537,7 @@ class Visualizer: plate: item is on a plate (soup are is different on a plate and pot) """ - if not isinstance(item, list): # can we remove this check?w + if not isinstance(item, list): # can we remove this check? if item["type"] in self.config or ( item["type"].startswith("Burnt") and item["type"].replace("Burnt", "") in self.config @@ -598,7 +598,7 @@ class Visualizer: ) elif "content_list" in item and item["content_list"]: triangle_offsets = create_polygon( - len(item["content_list"]), np.array([0, 10]) + len(item["content_list"]), np.array([0, grid_size * 0.15]) ) scale = 1 if len(item["content_list"]) == 1 else 0.6 for idx, o in enumerate(item["content_list"]): @@ -709,9 +709,10 @@ class Visualizer: # Multiple plates on plate return: if isinstance(occupied_by, list): for i, o in enumerate(occupied_by): + stack_pos = np.abs([pos[0], pos[1] - (i * grid_size * 0.075)]) self.draw_item( screen=screen, - pos=np.abs([pos[0], pos[1] - (i * 3)]), + pos=stack_pos, grid_size=grid_size, item=o, scale=item_scale, @@ -922,7 +923,7 @@ class Visualizer: def draw_recipe_image( self, screen: pygame.Surface, graph_dict, width, height, grid_size ) -> None: - screen.fill(self.config["GameWindow"]["background_color"]) + # screen.fill(self.config["GameWindow"]["background_color"]) positions_dict = graph_dict["layout"] positions = np.array(list(positions_dict.values())) positions = positions - positions.min(axis=0) diff --git a/cooperative_cuisine/pygame_2d_vis/gui.py b/cooperative_cuisine/pygame_2d_vis/gui.py index f6b8946357324fa55359821a99e35f186d25ab51..df472228b29051133df2872bc8b1b28db9d3d767 100644 --- a/cooperative_cuisine/pygame_2d_vis/gui.py +++ b/cooperative_cuisine/pygame_2d_vis/gui.py @@ -28,6 +28,7 @@ from cooperative_cuisine.game_server import ( ) from cooperative_cuisine.pygame_2d_vis.drawing import Visualizer from cooperative_cuisine.pygame_2d_vis.game_colors import colors +from cooperative_cuisine.server_results import PlayerInfo from cooperative_cuisine.state_representation import StateRepresentation from cooperative_cuisine.utils import ( url_and_port_arguments, @@ -42,7 +43,7 @@ class MenuStates(Enum): """Enumeration of "Page" types in the 2D pygame vis.""" Start = "Start" - ControllerTutorial = "ControllerTutorial" + Tutorial = "Tutorial" PreGame = "PreGame" Game = "Game" PostGame = "PostGame" @@ -67,7 +68,7 @@ class PlayerKeySet: pickup_key: pygame.key, switch_key: pygame.key, players: list[str], - joystick: int, + joystick: int | None, ): """Creates a player key set which contains information about which keyboard keys control the player. @@ -96,11 +97,16 @@ class PlayerKeySet: self.joystick = joystick def set_controlled_players(self, controlled_players: list[str]) -> None: + """Sets the controlled players for this keyset. + Args: + controlled_players: The players controlled by this keyset. + """ self.controlled_players = controlled_players self.current_player = self.controlled_players[0] self.current_idx = 0 def next_player(self) -> None: + """Switches to the next player in the list of controlled players.""" self.current_idx = (self.current_idx + 1) % len(self.controlled_players) if self.other_keyset: for ok in self.other_keyset: @@ -109,6 +115,9 @@ class PlayerKeySet: return self.current_player = self.controlled_players[self.current_idx] + def __repr__(self) -> str: + return f"Keyset(current={self.current_player}, players={self.controlled_players}, joy={self.joystick})" + class PyGameGUI: """Visualisation of the overcooked environment and reading keyboard inputs using pygame.""" @@ -184,6 +193,8 @@ class PyGameGUI: self.window_width_fullscreen /= 2 self.window_height_fullscreen /= 2 + self.game_width = 0 + self.game_height = 0 self.window_width_windowed = self.min_width self.window_height_windowed = self.min_height self.kitchen_width = 1 @@ -232,7 +243,7 @@ class PyGameGUI: pickup_key=pygame.K_e, switch_key=pygame.K_SPACE, players=players, - joystick=0, + joystick=None, ) key_set2 = PlayerKeySet( move_keys=[pygame.K_LEFT, pygame.K_RIGHT, pygame.K_UP, pygame.K_DOWN], @@ -240,10 +251,16 @@ class PyGameGUI: pickup_key=pygame.K_o, switch_key=pygame.K_p, players=players, - joystick=1, + joystick=None, ) key_sets = [key_set1, key_set2] + if self.joysticks: + for idx, key in enumerate(self.joysticks.keys()): + if idx >= len(key_sets): + break + key_sets[idx].joystick = key + if disjunct: key_set1.set_controlled_players(players[::2]) key_set2.set_controlled_players(players[1::2]) @@ -282,7 +299,7 @@ class PyGameGUI: ) self.send_action(action) - def handle_joy_stick_input(self, joysticks): + def handle_joy_stick_input(self, joysticks: dict[int, pygame.joystick.Joystick]): """Handles joystick inputs for movement every frame Args: joysticks: list of joysticks @@ -323,7 +340,7 @@ class PyGameGUI: ) self.send_action(action) - def handle_key_event(self, event): + def handle_key_event(self, event: pygame.event.Event): """Handles key events for the pickup and interaction keys. Pickup is a single action, for interaction keydown and keyup is necessary, because the player has to be able to hold the key down. @@ -353,7 +370,9 @@ class PyGameGUI: if event.type == pygame.KEYDOWN: key_set.next_player() - def handle_joy_stick_event(self, event, joysticks): + def handle_joy_stick_event( + self, event: pygame.event.Event, joysticks: dict[int, pygame.joystick.Joystick] + ): """Handles joy stick events for the pickup and interaction keys. Pickup is a single action, for interaction buttondown and buttonup is necessary, because the player has to be able to hold the button down. @@ -395,6 +414,7 @@ class PyGameGUI: key_set.next_player() def set_window_size(self): + """Sets the window size based on fullscreen or not.""" if self.fullscreen: flags = pygame.FULLSCREEN self.window_width = self.window_width_fullscreen @@ -412,12 +432,13 @@ class PyGameGUI: flags=flags, ) - def reset_window_size(self): - self.game_width = 0 - self.game_height = 0 - self.set_window_size() + def set_game_size(self, max_width: float = None, max_height: float = None): + """Sets the game size based on the kitchen size and the current window size. - def set_game_size(self, max_width=None, max_height=None): + Args: + max_width: Maximum width of the game screen. + max_height: Maximum height of the game screen. + """ if max_width is None: max_width = self.window_width - (2 * self.screen_margin) if max_height is None: @@ -458,6 +479,8 @@ class PyGameGUI: ) def init_ui_elements(self): + """Creates all UI elements. Creates lists of which elements belong on which screen.""" + self.manager = pygame_gui.UIManager( (self.window_width, self.window_height), starting_language=self.language, @@ -632,9 +655,8 @@ class PyGameGUI: ) size = 50 - add_player_button_rect = pygame.Rect((0, 0), (size, size)) self.add_human_player_button = pygame_gui.elements.UIButton( - relative_rect=add_player_button_rect, + relative_rect=pygame.Rect((0, 0), (size, size)), text="+", manager=self.manager, object_id="#quantity_button", @@ -642,10 +664,10 @@ class PyGameGUI: anchors={"left_target": self.added_bots_label, "centery": "centery"}, ) - remove_player_button_rect = pygame.Rect((0, 0), (size, size)) - remove_player_button_rect.right = 0 + rect = pygame.Rect((0, 0), (size, size)) + rect.right = 0 self.remove_human_button = pygame_gui.elements.UIButton( - relative_rect=remove_player_button_rect, + relative_rect=rect, text="-", manager=self.manager, object_id="#quantity_button", @@ -657,9 +679,8 @@ class PyGameGUI: }, ) - add_bot_button_rect = pygame.Rect((0, 0), (size, size)) self.add_bot_button = pygame_gui.elements.UIButton( - relative_rect=add_bot_button_rect, + relative_rect=pygame.Rect((0, 0), (size, size)), text="+", manager=self.manager, object_id="#quantity_button", @@ -667,10 +688,10 @@ class PyGameGUI: anchors={"left_target": self.added_bots_label, "centery": "centery"}, ) - remove_bot_button_rect = pygame.Rect((0, 0), (size, size)) - remove_bot_button_rect.right = 0 + rect = pygame.Rect((0, 0), (size, size)) + rect.right = 0 self.remove_bot_button = pygame_gui.elements.UIButton( - relative_rect=remove_bot_button_rect, + relative_rect=rect, text="-", manager=self.manager, object_id="#quantity_button", @@ -830,22 +851,6 @@ class PyGameGUI: self.orders_container_width = ( self.window_width - (2 * self.buttons_width) - (self.buttons_width * 0.7) ) - # rect = pygame.Rect( - # 0, - # 0, - # self.orders_container_width, - # self.screen_margin, - # ) - # self.orders_container = pygame_gui.elements.UIPanel( - # relative_rect=rect, - # manager=self.manager, - # object_id="#graph_container", - # anchors={ - # "top": "top", - # "left": "left", - # "left_target": self.orders_label, - # }, - # ) self.orders_image = pygame_gui.elements.UIImage( relative_rect=pygame.Rect( @@ -1067,7 +1072,12 @@ class PyGameGUI: final_text_container, ] - def show_screen_elements(self, elements: list): + def show_screen_elements(self, elements: list[pygame_gui.core.UIElement]): + """Hides all UI elements and shows the elements in the list and elements in self.on_all_screens. + + Args: + elements: List of UI elements to show. + """ all_elements = ( self.start_screen_elements + self.tutorial_screen_elements @@ -1083,7 +1093,8 @@ class PyGameGUI: for element in elements + self.on_all_screens: element.show() - def update_tutorial_screen(self): + def setup_tutorial_screen(self): + """Updates the tutorial screen with the current tutorial image and the continue button.""" self.show_screen_elements(self.tutorial_screen_elements) self.set_game_size( @@ -1107,19 +1118,17 @@ class PyGameGUI: grid_size=self.window_height / 18, ) self.tutorial_graph_image.set_image(tutorial_graph_surface) - # self.tutorial_graph_image.set_dimensions((self.game_width, self.game_height)) def update_screen_elements(self): + """Shows and hides the UI elements based on the current menu state.""" match self.menu_state: case MenuStates.Start: self.show_screen_elements(self.start_screen_elements) - if self.CONNECT_WITH_STUDY_SERVER: self.bot_number_container.hide() - self.update_selection_elements() - case MenuStates.ControllerTutorial: - self.update_tutorial_screen() + case MenuStates.Tutorial: + self.setup_tutorial_screen() case MenuStates.PreGame: self.init_ui_elements() self.show_screen_elements(self.pregame_screen_elements) @@ -1129,7 +1138,7 @@ class PyGameGUI: case MenuStates.PostGame: self.init_ui_elements() self.show_screen_elements(self.postgame_screen_elements) - self.update_postgame_screen(self.last_state) + self.update_post_game_screen(self.last_state) if self.last_level: self.next_game_button.hide() self.finish_study_button.show() @@ -1140,37 +1149,27 @@ class PyGameGUI: self.show_screen_elements(self.end_screen_elements) def draw_main_window(self): + """Main draw function. Draws the main window and updates the UI. + Draws the game screen in Game and ControllerTutorial screen.""" self.main_window.fill( colors[self.visualization_config["GameWindow"]["background_color"]] ) - match self.menu_state: - case MenuStates.ControllerTutorial: - self.draw_tutorial_screen_frame() - case MenuStates.Game: - self.draw_game_screen_frame() + if self.menu_state == MenuStates.Tutorial: + self.draw_game_screen_frame(tutorial=True) + elif self.menu_state == MenuStates.Game: + self.draw_game_screen_frame(tutorial=False) self.manager.draw_ui(self.main_window) self.manager.update(self.time_delta) pygame.display.flip() - def draw_tutorial_screen_frame(self): - self.handle_keys() - self.handle_joy_stick_input(joysticks=self.joysticks) - - state = self.request_state() - self.vis.draw_gamescreen( - self.game_screen, - state, - self.grid_size, - [int(k.current_player) for k in self.key_sets], - ) - - game_screen_rect = self.game_screen.get_rect() - game_screen_rect.center = self.game_center - self.main_window.blit(self.game_screen, game_screen_rect) + def update_post_game_screen(self, state: dict): + """Updates the post game screen with the final score and the completed meals. - def update_postgame_screen(self, state): + Args: + state: The game state returned by the environment, containing served meals and score. + """ score = state["score"] # self.score_conclusion.set_text(f"Your final score is {score}. Hurray!") self.score_conclusion.set_text( @@ -1225,47 +1224,6 @@ class PyGameGUI: anchors=anchors, ) - text = ":" - rect = pygame.Rect( - (0, 0), - (container_width / 10, row_height), - ) - rect.left = 0 - meal_label = pygame_gui.elements.UILabel( - text=text, - relative_rect=rect, - manager=self.manager, - container=container, - object_id="#served_meal", - anchors={"center": "center"}, - ) - - cook_surface = pygame.Surface( - (row_height, row_height), flags=pygame.SRCALPHA - ) - player_idx = int(player) - player_color = colors[self.vis.player_colors[player_idx]] - self.vis.draw_cook( - screen=cook_surface, - grid_size=row_height, - pos=np.array([row_height / 2, row_height / 2]), - color=player_color, - facing=np.array([0, 1]), - ) - rect = cook_surface.get_rect() - rect.right = 0 - cook_image = pygame_gui.elements.UIImage( - relative_rect=rect, - image_surface=cook_surface, - manager=self.manager, - container=container, - anchors={ - "centery": "centery", - "right": "right", - "right_target": meal_label, - }, - ) - meal_surface = pygame.Surface( (row_height, row_height), flags=pygame.SRCALPHA ) @@ -1276,23 +1234,65 @@ class PyGameGUI: screen=meal_surface, grid_size=row_height, ) + + meal_name = meal.split("(")[0] self.vis.draw_item( pos=np.array([row_height / 2, row_height / 2]), - item={"type": meal}, + item={"type": meal_name}, plate=True, screen=meal_surface, grid_size=row_height, ) rect = meal_surface.get_rect() - # rect.left = 0 + rect.center = (rect.center[0] - (self.buttons_width * 0.3), rect.center[1]) meal_image = pygame_gui.elements.UIImage( relative_rect=rect, image_surface=meal_surface, manager=self.manager, container=container, - anchors={"centery": "centery", "left_target": meal_label}, + anchors={"center": "center"}, ) + rect = pygame.Rect( + (0, 0), + (container_width / 4, row_height), + ) + rect.left = 0 + meal_label = pygame_gui.elements.UILabel( + text="translations.was_served", + relative_rect=rect, + manager=self.manager, + container=container, + object_id="#was_served", + anchors={"centery": "centery", "left_target": meal_image}, + ) + + # cook_surface = pygame.Surface( + # (row_height, row_height), flags=pygame.SRCALPHA + # ) + # player_idx = int(player) + # player_color = colors[self.vis.player_colors[player_idx]] + # self.vis.draw_cook( + # screen=cook_surface, + # grid_size=row_height, + # pos=np.array([row_height / 2, row_height / 2]), + # color=player_color, + # facing=np.array([0, 1]), + # ) + # rect = cook_surface.get_rect() + # rect.right = 0 + # cook_image = pygame_gui.elements.UIImage( + # relative_rect=rect, + # image_surface=cook_surface, + # manager=self.manager, + # container=container, + # anchors={ + # "centery": "centery", + # "right": "right", + # "right_target": meal_label, + # }, + # ) + last_completed_meals.append(container) self.scroll_space_completed_meals.set_scrollable_area_dimensions( @@ -1300,50 +1300,23 @@ class PyGameGUI: ) def exit_game(self): + """Exits the game.""" self.menu_state = MenuStates.PostGame if self.CONNECT_WITH_STUDY_SERVER: self.send_level_done() self.disconnect_websockets() - self.update_postgame_screen(self.last_state) + self.update_post_game_screen(self.last_state) self.update_screen_elements() self.beeped_once = False - def draw_game_screen_frame(self): - self.last_state = self.request_state() - - self.handle_keys() - self.handle_joy_stick_input(joysticks=self.joysticks) - - if not self.beeped_once and self.last_state["all_players_ready"]: - self.beeped_once = True - self.play_bell_sound() - - if self.last_state["ended"]: - self.exit_game() - - else: - self.draw_game(self.last_state) - - game_screen_rect = self.game_screen.get_rect() - - game_screen_rect.center = [ - self.window_width // 2, - self.window_height // 2, - ] - - self.main_window.blit(self.game_screen, game_screen_rect) - - if not self.last_state["all_players_ready"]: - self.wait_players_label.show() - else: - self.wait_players_label.hide() - - def draw_game(self, state): + def draw_game(self, state: dict): """Main visualization function. - Args: state: The game state returned by the environment.""" + Args: + state: The game state returned by the environment. + """ self.vis.draw_gamescreen( self.game_screen, state, @@ -1351,7 +1324,6 @@ class PyGameGUI: [int(k.current_player) for k in self.key_sets], ) - # orders_surface = pygame.Surface((self.orders_container_width, self.screen_margin)) self.vis.draw_orders( screen=self.orders_image.image, state=state, @@ -1360,7 +1332,6 @@ class PyGameGUI: height=self.screen_margin, config=self.visualization_config, ) - # self.orders_image.set_image(orders_surface) border = self.visualization_config["GameWindow"]["game_border_size"] border_rect = pygame.Rect( @@ -1397,13 +1368,69 @@ class PyGameGUI: ), ) - def update_score_label(self, state): + def draw_game_screen_frame(self, tutorial: bool = False): + """Main visualization function for the game screen. + + Args: + tutorial: If True, the tutorial screen is drawn, which is a simplified version of the game screen. + """ + self.last_state = self.request_state() + + self.handle_keys() + self.handle_joy_stick_input(joysticks=self.joysticks) + + if tutorial: + self.vis.draw_gamescreen( + self.game_screen, + self.last_state, + self.grid_size, + [int(k.current_player) for k in self.key_sets], + ) + game_screen_rect = self.game_screen.get_rect() + game_screen_rect.center = self.game_center + self.main_window.blit(self.game_screen, game_screen_rect) + return + else: + if not self.beeped_once and self.last_state["all_players_ready"]: + self.beeped_once = True + self.play_bell_sound() + + if self.last_state["ended"]: + self.exit_game() + + else: + self.draw_game(self.last_state) + + game_screen_rect = self.game_screen.get_rect() + game_screen_rect.center = [ + self.window_width // 2, + self.window_height // 2, + ] + self.main_window.blit(self.game_screen, game_screen_rect) + + if not self.last_state["all_players_ready"]: + self.wait_players_label.show() + else: + self.wait_players_label.hide() + + def update_score_label(self, state: dict): + """Updates the score label. + + Args: + state: The game state returned by the environment. + + """ score = state["score"] self.score_label.set_text( "translations.score", text_kwargs={"score": str(score)} ) def update_remaining_time(self, remaining_time: float): + """Updates the remaining time label. + + Args: + remaining_time: The remaining time in seconds. + """ hours, rem = divmod(int(remaining_time), 3600) minutes, seconds = divmod(rem, 60) display_time = f"{minutes}:{'%02d' % seconds}" @@ -1411,7 +1438,12 @@ class PyGameGUI: "translations.time_remaining", text_kwargs={"time": display_time} ) - def create_env_on_game_server(self, tutorial): + def create_env_on_game_server(self, tutorial: bool): + """Starts an environment on the game server. + + Args: + tutorial: If True, a tutorial environment is created. Else a normal game environment is created. + """ if tutorial: layout_path = ROOT_DIR / "configs" / "layouts" / "tutorial.layout" environment_config_path = ROOT_DIR / "configs" / "tutorial_env_config.yaml" @@ -1422,7 +1454,7 @@ class PyGameGUI: ] # layout_path = self.layout_file_paths[self.current_layout_idx] - item_info_path = ROOT_DIR / "configs" / "item_info.yaml" + item_info_path = ROOT_DIR / "configs" / "item_info_debug.yaml" with open(item_info_path, "r") as file: item_info = file.read() with open(layout_path, "r") as file: @@ -1440,6 +1472,7 @@ class PyGameGUI: environment_config=environment_config, layout_config=layout, seed=seed, + env_name=layout_path.stem ).model_dump(mode="json") # print(CreateEnvironmentConfig.model_validate_json(json_data=creation_json)) @@ -1450,7 +1483,7 @@ class PyGameGUI: if env_info.status_code == 403: raise ValueError(f"Forbidden Request: {env_info.json()['detail']}") elif env_info.status_code == 409: - print("CONFLICT") + log.warning("CONFLICT") env_info = env_info.json() assert isinstance(env_info, dict), "Env info must be a dictionary" self.current_env_id = env_info["env_id"] @@ -1476,6 +1509,7 @@ class PyGameGUI: self.level_info["number_players"] = num_players def update_pregame_screen(self): + """Updates the pregame screen. Possible recipes in the level are displayed.""" self.level_name_label.set_text( "translations.level_name", text_kwargs={"level": self.level_info["name"]} ) @@ -1574,6 +1608,8 @@ class PyGameGUI: ) def setup_tutorial(self): + """Sets up the tutorial environment. This includes creating the environment + on the game server, and setting up the connection.""" answer = requests.post( f"{self.request_url}/connect_to_tutorial/{self.participant_id}" ) @@ -1584,7 +1620,6 @@ class PyGameGUI: if answer.status_code == 200: answer_json = answer.json() self.player_info = answer_json["player_info"]["0"] - print("TUTORIAL PLAYER INFO", self.player_info) self.level_info = answer_json["level_info"] self.player_info = {self.player_info["player_id"]: self.player_info} else: @@ -1594,7 +1629,9 @@ class PyGameGUI: log.warning("Could not create tutorial.") def get_game_connection(self): - if self.menu_state == MenuStates.ControllerTutorial: + """Sets up a connection to the game server. + This includes getting the player info, level info and player keys""" + if self.menu_state == MenuStates.Tutorial: self.setup_tutorial() self.key_sets = self.setup_player_keys(["0"], 1, False) self.vis.create_player_colors(1) @@ -1605,7 +1642,6 @@ class PyGameGUI: if answer.status_code == 200: answer_json = answer.json() self.player_info = answer_json["player_info"] - print("GAME PLAYER INFO", self.player_info) self.level_info = answer_json["level_info"] self.last_level = self.level_info["last_level"] @@ -1626,10 +1662,17 @@ class PyGameGUI: ) self.player_ids = list(self.player_info.keys()) - def create_and_connect_bot(self, player_id, player_info): + def create_and_connect_bot(self, player_id: str, player_info: PlayerInfo): + """Creates a bot process and connects it to the game server. + + Args: + player_id: The id/name of the player. + player_info: Player info containing client_id, player_hash player_id and websocket_url. + """ player_hash = player_info["player_hash"] print( - f'--general_plus="agent_websocket:{player_info["websocket_url"]};player_hash:{player_hash};agent_id:{player_id}"' + f'--general_plus="agent_websocket:{player_info["websocket_url"]};' + f'player_hash:{player_hash};agent_id:{player_id}"' ) if self.USE_AAAMBOS_AGENT: sub = Popen( @@ -1642,7 +1685,8 @@ class PyGameGUI: str(ROOT_DIR / "configs" / "agents" / "arch_config.yml"), "--run_config", str(ROOT_DIR / "configs" / "agents" / "run_config.yml"), - f'--general_plus="agent_websocket:{player_info["websocket_url"]};player_hash:{player_hash};agent_id:{player_id}"', + f'--general_plus="agent_websocket:{player_info["websocket_url"]};' + f'player_hash:{player_hash};agent_id:{player_id}"', f"--instance={player_hash}", ] ), @@ -1664,6 +1708,8 @@ class PyGameGUI: self.sub_processes.append(sub) def connect_websockets(self): + """Connects the websockets of the players to the game server. + If the player is a bot, a bot process is created""" for p, (player_id, player_info) in enumerate(self.player_info.items()): if p < self.number_humans_to_be_added: # add player websockets @@ -1680,18 +1726,23 @@ class PyGameGUI: self.websockets[player_id] = websocket else: - # create bots and add bot websockets self.create_and_connect_bot(player_id, player_info) if p == 0: self.state_player_id = player_id def setup_game(self): + """Sets up prerequisites for the game. This includes connecting the websockets, creating player colors + in the vis and setting the kitchen size.""" self.connect_websockets() self.vis.create_player_colors(self.level_info["number_players"]) self.kitchen_width, self.kitchen_height = self.level_info["kitchen_size"] def stop_game_on_server(self, reason: str) -> None: + """Stops the game directly on the game server. + Args: + reason: The reason for stopping the game. + """ log.debug(f"Stopping game: {reason}") if not self.CONNECT_WITH_STUDY_SERVER: answer = requests.post( @@ -1710,11 +1761,14 @@ class PyGameGUI: ) def send_tutorial_finished(self): + """Signals the study server that the tutorial was finished.""" requests.post( f"{self.request_url}/disconnect_from_tutorial/{self.participant_id}", ) def finished_button_press(self): + """Gets called when the finished button is pressed. + Stops the game on the game server if the study server is not used.""" if not self.CONNECT_WITH_STUDY_SERVER: self.stop_game_on_server("finished_button_pressed") self.menu_state = MenuStates.PostGame @@ -1722,6 +1776,7 @@ class PyGameGUI: self.update_screen_elements() def fullscreen_button_press(self): + """Toggles between fullscreen and windowed mode.""" self.fullscreen = not self.fullscreen self.set_window_size() self.init_ui_elements() @@ -1729,6 +1784,7 @@ class PyGameGUI: self.update_screen_elements() def reset_gui_values(self): + """Reset the values of the GUI elements to their default values. Default values are defined here.""" self.currently_controlled_player_idx = 0 self.number_humans_to_be_added = 1 self.number_bots_to_be_added = 0 @@ -1739,6 +1795,8 @@ class PyGameGUI: self.multiple_keysets = False def update_selection_elements(self): + """Updates the selection elements of the GUI. This includes the number of players, + the number of bots, the split players button and the multiple keysets button.""" if self.number_humans_to_be_added == 1: self.remove_human_button.disable() self.multiple_keysets_button.hide() @@ -1813,6 +1871,7 @@ class PyGameGUI: self.websockets[action.player].recv() def request_state(self): + """Requests the current state of the game environment from the game server.""" message_dict = { "type": PlayerRequestType.GET_STATE.value, "action": None, @@ -1823,7 +1882,19 @@ class PyGameGUI: state = json.loads(self.websockets[self.state_player_id].recv()) return state + def exit_tutorial(self): + """Exits the tutorial. Disconnects the websockets and signals the study server that the tutorial was + finished if the study server is used. Otherwise, the game is stopped on the game server. + """ + self.disconnect_websockets() + self.menu_state = MenuStates.PreGame + if self.CONNECT_WITH_STUDY_SERVER: + self.send_tutorial_finished() + else: + self.stop_game_on_server("tutorial_finished") + def disconnect_websockets(self): + """Disconnects the websockets. Kills all subprocesses that are running bots.""" for sub in self.sub_processes: try: if self.USE_AAAMBOS_AGENT: @@ -1842,15 +1913,18 @@ class PyGameGUI: for websocket in self.websockets.values(): websocket.close() - def play_bell_sound(self): + @staticmethod + def play_bell_sound(): + """Plays a bell sound when the game starts.""" bell_path = str(ROOT_DIR / "pygame_2d_vis" / "sync_bell.wav") mixer.init() mixer.music.load(bell_path) - mixer.music.set_volume(0.9) + mixer.music.set_volume(0.7) mixer.music.play() log.log(logging.INFO, "Started game, played bell sound") def start_study(self): + """Starts the study on the study server.""" answer = requests.post( f"{self.request_url}/start_study/{self.participant_id}/{self.number_humans_to_be_added}" ) @@ -1859,13 +1933,14 @@ class PyGameGUI: self.get_game_connection() else: self.menu_state = MenuStates.Start - print( + log.warning( "COULD NOT START STUDY; Response:", answer.status_code, answer.json()["detail"], ) def send_level_done(self): + """Sends a message to the study server that the level was finished.""" answer = requests.post(f"{self.request_url}/level_done/{self.participant_id}") if answer.status_code != 200: log.warning( @@ -1875,22 +1950,24 @@ class PyGameGUI: ) def button_continue_postgame_pressed(self): + """Handles the continue button press on the postgame screen. If the study server is used, the connection to + a new game is set up. Otherwise, the start screen is shown. + """ if self.CONNECT_WITH_STUDY_SERVER: if not self.last_level: self.get_game_connection() else: - # self.current_layout_idx += 1 self.menu_state = MenuStates.Start return - # self.create_env_on_game_server(tutorial=False) - # if self.current_layout_idx == len(self.layout_file_paths) - 1: - # self.last_level = True - # else: - # log.debug(f"LEVEL: {self.layout_file_paths[self.current_layout_idx]}") - self.menu_state = MenuStates.PreGame def manage_button_event(self, button: pygame_gui.core.UIElement | None = None): + """Manages the button events. The button events are filtered by the current menu state and the button that + was pressed. + + Args: + button: The button that was pressed. + """ if button == self.quit_button: if self.fullscreen: self.fullscreen_button_press() @@ -1918,7 +1995,7 @@ class PyGameGUI: ): pass else: - self.menu_state = MenuStates.ControllerTutorial + self.menu_state = MenuStates.Tutorial if self.CONNECT_WITH_STUDY_SERVER: self.get_game_connection() else: @@ -1946,7 +2023,7 @@ class PyGameGUI: ############################################ - case MenuStates.ControllerTutorial: + case MenuStates.Tutorial: self.exit_tutorial() if self.CONNECT_WITH_STUDY_SERVER: self.start_study() @@ -1976,6 +2053,89 @@ class PyGameGUI: ############################################ + def add_joystick(self, event: pygame.event.Event): + """Adds a joystick to the list of joysticks and assigns it to a key set. + A pygame.JOYDEVICEADDED event is generated for every joystick connected at the start of the program. + + Args: + event: The event that is triggered when a joystick is connected. + """ + joy = pygame.joystick.Joystick(event.device_index) + self.joysticks[joy.get_instance_id()] = joy + + for key_set in self.key_sets: + if key_set.joystick is None: + key_set.joystick = joy.get_instance_id() + break + log.debug(f"Joystick {joy.get_instance_id()} connected") + + def remove_joystick(self, event: pygame.event.Event): + """Removes a joystick from the list of joysticks and unassigns it from a key set. + + Args: + event: The event that is triggered when a joystick is disconnected. + """ + del self.joysticks[event.instance_id] + for key_set in self.key_sets: + if key_set.joystick == event.instance_id: + key_set.joystick = None + log.debug(f"Joystick {event.instance_id} disconnected") + log.debug(f"Number of joysticks:" + str(pygame.joystick.get_count())) + + def process_gui_event(self, event: pygame.event.Event): + """Processes the pygame events. The events are filtered by their type and the current menu state. + + Args: + event: The pygame event that is processed. + """ + if event.type == pygame.QUIT: + self.disconnect_websockets() + self.running = False + + if event.type == pygame.JOYDEVICEADDED and pygame.joystick.get_count() > 0: + self.add_joystick(event) + if event.type == pygame.JOYDEVICEREMOVED: + self.remove_joystick(event) + + # Press enter key or controller start button instead of mouse button press + if ( + event.type == pygame.JOYBUTTONDOWN + and any([joy.get_button(7) for joy in self.joysticks.values()]) + or (event.type == pygame.KEYDOWN and event.key == pygame.K_RETURN) + ): + if self.menu_state == MenuStates.Start: + self.manage_button_event(self.start_button) + self.update_screen_elements() + + elif self.menu_state in [ + MenuStates.Tutorial, + MenuStates.PreGame, + MenuStates.PostGame, + ]: + self.manage_button_event(self.continue_button) + self.update_screen_elements() + + if event.type == pygame_gui.UI_BUTTON_PRESSED: + button = event.ui_element + self.manage_button_event(button) + if button in [ + self.start_button, + self.continue_button, + self.finish_study_button, + self.next_game_button, + ]: + self.update_screen_elements() + elif self.menu_state == MenuStates.Start: + self.update_selection_elements() + + if self.menu_state in [MenuStates.Game, MenuStates.Tutorial]: + if event.type in [pygame.KEYDOWN, pygame.KEYUP]: + self.handle_key_event(event) + if event.type in [pygame.JOYBUTTONDOWN, pygame.JOYBUTTONUP]: + self.handle_joy_stick_event(event, joysticks=self.joysticks) + + self.manager.process_events(event) + def start_pygame(self): """Starts pygame and the gui loop. Each frame the game state is visualized and keyboard inputs are read.""" log.debug(f"Starting pygame gui at {self.FPS} fps") @@ -1986,17 +2146,14 @@ class PyGameGUI: clock = pygame.time.Clock() - self.reset_window_size() + self.set_window_size() self.init_ui_elements() - self.reset_window_size() self.reset_gui_values() self.update_screen_elements() # Game loop self.running = True - # This dict can be left as-is, since pygame will generate a - # pygame.JOYDEVICEADDED event for every joystick connected - # at the start of the program. + # pygame.JOYDEVICEADDED event is generated for every joystick connected at the start of the program. self.joysticks = {} while self.running: @@ -2005,113 +2162,22 @@ class PyGameGUI: # PROCESSING EVENTS for event in pygame.event.get(): - if event.type == pygame.QUIT: - self.disconnect_websockets() - self.running = False - - # connect joystick - if ( - pygame.joystick.get_count() > 0 - and event.type == pygame.JOYDEVICEADDED - ): - # This event will be generated when the program starts for every - # joystick, filling up the list without needing to create them manually. - joy = pygame.joystick.Joystick(event.device_index) - self.joysticks[joy.get_instance_id()] = joy - print(f"Joystick {joy.get_instance_id()} connected") - - # disconnect joystick - if event.type == pygame.JOYDEVICEREMOVED: - del self.joysticks[event.instance_id] - print(f"Joystick {event.instance_id} disconnected") - print("Number of joysticks:", pygame.joystick.get_count()) - - # Press enter key or controller start button instead of mouse button press - if ( - event.type == pygame.JOYBUTTONDOWN - and any( - [ - self.joysticks and self.joysticks[i].get_button(7) - for i in range(len(self.joysticks)) - ] - ) - or ( - event.type == pygame.KEYDOWN - and event.key == pygame.K_RETURN - ) - ): - if self.menu_state == MenuStates.Start: - self.manage_button_event(self.start_button) - self.update_screen_elements() - - elif self.menu_state in [ - MenuStates.ControllerTutorial, - MenuStates.PreGame, - MenuStates.PostGame, - ]: - self.manage_button_event(self.continue_button) - self.update_screen_elements() - - if event.type == pygame_gui.UI_BUTTON_PRESSED: - button = event.ui_element - self.manage_button_event(button) - if button in [ - self.start_button, - self.continue_button, - self.finish_study_button, - self.next_game_button, - ]: - self.update_screen_elements() - elif self.menu_state == MenuStates.Start: - self.update_selection_elements() - if event.type in [ - pygame.KEYDOWN, - pygame.KEYUP, - ] and self.menu_state in [ - MenuStates.Game, - MenuStates.ControllerTutorial, - ]: - self.handle_key_event(event) - - if event.type in [ - pygame.JOYBUTTONDOWN, - pygame.JOYBUTTONUP, - ] and self.menu_state in [ - MenuStates.Game, - MenuStates.ControllerTutorial, - ]: - self.handle_joy_stick_event(event, joysticks=self.joysticks) - - self.manager.process_events(event) + self.process_gui_event(event) # DRAWING self.draw_main_window() except (KeyboardInterrupt, SystemExit): self.running = False - self.disconnect_websockets() - if not self.CONNECT_WITH_STUDY_SERVER: - self.stop_game_on_server("Program exited.") - if self.fullscreen_button: - self.fullscreen_button_press() self.disconnect_websockets() if not self.CONNECT_WITH_STUDY_SERVER: self.stop_game_on_server("Program exited.") - if self.fullscreen: self.fullscreen_button_press() pygame.quit() sys.exit() - def exit_tutorial(self): - self.disconnect_websockets() - self.menu_state = MenuStates.PreGame - if self.CONNECT_WITH_STUDY_SERVER: - self.send_tutorial_finished() - else: - self.stop_game_on_server("tutorial_finished") - def main( study_url: str, @@ -2141,7 +2207,8 @@ if __name__ == "__main__": parser = argparse.ArgumentParser( prog="Cooperative Cuisine 2D PyGame Visualization", description="PyGameGUI: a PyGame 2D Visualization window.", - 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) diff --git a/cooperative_cuisine/pygame_2d_vis/gui_theme.json b/cooperative_cuisine/pygame_2d_vis/gui_theme.json index a67753356698864486856ae56c7e23ab65f296a8..5ceaf4397e562c1e82e53566b5f09e91598e41f9 100644 --- a/cooperative_cuisine/pygame_2d_vis/gui_theme.json +++ b/cooperative_cuisine/pygame_2d_vis/gui_theme.json @@ -226,5 +226,10 @@ "border_width": "0", "shadow_width": "0" } + }, + "#was_served": { + "misc": { + "text_horiz_alignment": "left" + } } } diff --git a/cooperative_cuisine/pygame_2d_vis/images/star.png b/cooperative_cuisine/pygame_2d_vis/images/star.png new file mode 100644 index 0000000000000000000000000000000000000000..2df3847f26947b617909bebc0d43ac842cd783b6 Binary files /dev/null and b/cooperative_cuisine/pygame_2d_vis/images/star.png differ diff --git a/cooperative_cuisine/pygame_2d_vis/locales/translations.de.json b/cooperative_cuisine/pygame_2d_vis/locales/translations.de.json index f792c21ff567d7a47aecbe1c44a843251253a2b4..a772563275311776808d4eaf1b377bdf4c072406 100644 --- a/cooperative_cuisine/pygame_2d_vis/locales/translations.de.json +++ b/cooperative_cuisine/pygame_2d_vis/locales/translations.de.json @@ -12,7 +12,8 @@ "continue": "Weiter", "salad_recipe": "Rezept für Salat:", "recipes_in_this_level": "Rezepte in diesem Level:", - "level_name": "Level: %{level}", + "level_name": "%{level}", + "was_served": " wurde serviert", "waiting_for_players": "WARTE AUF ANDERE SPIELER", "orders": "Bestellungen:", "score": "Punktestand: %{score}", diff --git a/cooperative_cuisine/pygame_2d_vis/locales/translations.en.json b/cooperative_cuisine/pygame_2d_vis/locales/translations.en.json index 5aae6e456bf73bcde431f6ecb64a7d2678bfbca5..3e1b3f392fb7cc071e60695cf5f89798eb793516 100644 --- a/cooperative_cuisine/pygame_2d_vis/locales/translations.en.json +++ b/cooperative_cuisine/pygame_2d_vis/locales/translations.en.json @@ -12,7 +12,8 @@ "continue": "Continue", "salad_recipe": "Salad recipe:", "recipes_in_this_level": "Recipes in this level:", - "level_name": "Level: %{level}", + "level_name": "%{level}", + "was_served": " was served", "waiting_for_players": "WAITING FOR OTHER PLAYERS", "orders": "Orders:", "score": "Score: %{score}", @@ -22,7 +23,7 @@ "hurray": "Hurray!", "completed_meals": "Completed meals:", "completed_level": "Completed Level: %{level}", - "next_game": "Next study", + "next_game": "Next game", "finish_study": " Finish study", "thank_you": "Thank you for participating in this study!", "signal_supervisor": " Please signal the study supervisor that the study is finished.", diff --git a/cooperative_cuisine/pygame_2d_vis/visualization.yaml b/cooperative_cuisine/pygame_2d_vis/visualization.yaml index 954ff07d534c3d383d720fe7c1c4abf347cd977c..6f1fbe54331287555d4c9e25f4e2fd1bdf4fdb41 100644 --- a/cooperative_cuisine/pygame_2d_vis/visualization.yaml +++ b/cooperative_cuisine/pygame_2d_vis/visualization.yaml @@ -1,7 +1,7 @@ # colors: https://www.webucator.com/article/python-color-constants-module/ Gui: - language: "en" + language: "de" use_player_cook_sprites: True show_interaction_range: False show_counter_centers: False @@ -109,9 +109,10 @@ Dispenser: ServingWindow: parts: - type: image - path: images/arrow_right.png - size: 1 - center_offset: [ 0, 0 ] + path: images/star.png + size: 0.8 + center_offset: [ 0, -0.02 ] + rotate_image: False - type: image path: images/bell_gold.png size: 0.5 diff --git a/cooperative_cuisine/reinforcement_learning/gym_env.py b/cooperative_cuisine/reinforcement_learning/gym_env.py index 4bb079e3b45372e8d208df59b49da035b419a67a..db6357bac6a62b1c73c731a0c25dcd55f5dac13b 100644 --- a/cooperative_cuisine/reinforcement_learning/gym_env.py +++ b/cooperative_cuisine/reinforcement_learning/gym_env.py @@ -129,12 +129,12 @@ class EnvGymWrapper(Env): def __init__(self): super().__init__() - self.gridsize = 30 + self.gridsize = 40 - self.randomize_counter_placement = True + self.randomize_counter_placement = False self.use_rgb_obs = False # if False uses simple vectorized state self.full_vector_state = True - self.onehot_state = False + self.onehot_state = True self.env: Environment = Environment( env_config=environment_config, diff --git a/cooperative_cuisine/reinforcement_learning/pearl_test.py b/cooperative_cuisine/reinforcement_learning/pearl_test.py new file mode 100644 index 0000000000000000000000000000000000000000..c4cded0bfed470c2bcf60ea7e306ba21de4053ab --- /dev/null +++ b/cooperative_cuisine/reinforcement_learning/pearl_test.py @@ -0,0 +1,68 @@ +import cv2 +from pearl.action_representation_modules.one_hot_action_representation_module import ( + OneHotActionTensorRepresentationModule, +) +from pearl.pearl_agent import PearlAgent +from pearl.policy_learners.sequential_decision_making.deep_q_learning import ( + DeepQLearning, +) +from pearl.replay_buffers.sequential_decision_making.fifo_off_policy_replay_buffer import ( + FIFOOffPolicyReplayBuffer, +) +from pearl.utils.instantiations.environments.gym_environment import GymEnvironment + +from cooperative_cuisine.reinforcement_learning.gym_env import EnvGymWrapper + +custom = True +if custom: + env = GymEnvironment(EnvGymWrapper()) +else: + env = GymEnvironment("LunarLander-v2", render_mode="rgb_array") + +num_actions = env.action_space.n +agent = PearlAgent( + policy_learner=DeepQLearning( + state_dim=env.observation_space.shape[0], + action_space=env.action_space, + hidden_dims=[64, 64], + training_rounds=20, + action_representation_module=OneHotActionTensorRepresentationModule( + max_number_actions=num_actions + ), + ), + replay_buffer=FIFOOffPolicyReplayBuffer(10_000), +) + +for i in range(40): + print(i) + observation, action_space = env.reset() + agent.reset(observation, action_space) + done = False + while not done: + action = agent.act(exploit=False) + action_result = env.step(action) + agent.observe(action_result) + agent.learn() + done = action_result.done + +if custom: + env = GymEnvironment(EnvGymWrapper()) +else: + env = GymEnvironment("LunarLander-v2", render_mode="human") + +for i in range(40): + print(i) + observation, action_space = env.reset() + agent.reset(observation, action_space) + done = False + while not done: + action = agent.act(exploit=False) + action_result = env.step(action) + agent.observe(action_result) + agent.learn() + done = action_result.done + + if custom: + img = env.env.render() + cv2.imshow("image", img[:, :, ::-1]) + cv2.waitKey(1) diff --git a/cooperative_cuisine/state_representation.py b/cooperative_cuisine/state_representation.py index 277be355dcf54fc66cc51d9a923ced451d37f897..209bf7d98ad3157f093071b23a479fd89563a637 100644 --- a/cooperative_cuisine/state_representation.py +++ b/cooperative_cuisine/state_representation.py @@ -6,6 +6,10 @@ from datetime import datetime from enum import Enum from typing import Any +import networkx +import numpy as np +import numpy.typing as npt +from networkx import Graph from pydantic import BaseModel from typing_extensions import Literal, TypedDict @@ -186,6 +190,90 @@ class StateRepresentation(BaseModel): """Added by the game server, indicate if all players are ready and actions are passed to the environment.""" +def astar_heuristic(x, y): + """Heuristic distance function used in astar algorithm.""" + return np.linalg.norm(np.array(x) - np.array(y)) + + +def create_movement_graph(state: StateRepresentation, diagonal=True) -> Graph: + """ + Creates a graph which represents the connections of empty kitchen tiles and such + possible coarse movements of an agent. + Args: + state: State representation to determine the graph to. + diagonal: if True use 8 way connection, i.e. diagonal connections between the spaces. + + Returns: Graph representing the connections between empty kitchen tiles. + """ + width, height = state["kitchen"]["width"], state["kitchen"]["height"] + free_space = np.ones((width, height), dtype=bool) + for counter in state["counters"]: + grid_idx = np.array(counter["pos"]).round().astype(int) + free_space[grid_idx[0], grid_idx[1]] = False + + graph = networkx.Graph() + for i in range(width): + for j in range(height): + if free_space[i, j]: + graph.add_node((i, j)) + + if diagonal: + for di in range(-1, 2): + for dj in range(-1, 2): + x, y = i + di, j + dj + if ( + 0 <= x < width + and 0 < y < height + and free_space[x, y] + and (di, dj) != (0, 0) + ): + if np.sum(np.abs(np.array([di, dj]))) == 2: + if free_space[i + di, j] and free_space[i, j + dj]: + graph.add_edge( + (i, j), + (x, y), + weight=np.linalg.norm( + np.array([i - x, j - y]) + ), + ) + else: + graph.add_edge( + (i, j), + (x, y), + weight=np.linalg.norm(np.array([i - x, j - y])), + ) + else: + for x, y in [(i - 1, j), (i + 1, j), (i, j - 1), (i, j + 1)]: + if 0 <= x < width and 0 <= y < height and free_space[x, y]: + graph.add_edge( + (i, j), + (x, y), + weight=1, + ) + return graph + + +def restrict_movement_graph( + graph: Graph, + player_positions: list[tuple[float, float] | list[float]] | npt.NDArray[float], +) -> Graph: + """Modifies a given movement graph. Removed the nodes of spaces on which players stand. + + Args: + graph: The graph to modify. + player_positions: Positions of players. + + Returns: The modified graph without nodes where players stand. + + """ + copied = graph.copy() + for pos in player_positions: + tup = tuple(np.array(pos).round().astype(int)) + if tup in copied.nodes.keys(): + copied.remove_node(tup) + return copied + + def create_json_schema() -> dict[str, Any]: """Create a json scheme of the state representation of an environment.""" return StateRepresentation.model_json_schema() diff --git a/cooperative_cuisine/study_server.py b/cooperative_cuisine/study_server.py index 55ab0275c72f08724a32f525014d258fcbff181b..27896e71b25d73424f49e90a8fe6aa6ddd5afd23 100644 --- a/cooperative_cuisine/study_server.py +++ b/cooperative_cuisine/study_server.py @@ -18,6 +18,7 @@ import os import random import signal import subprocess +import uuid from pathlib import Path from subprocess import Popen from typing import Tuple, Any @@ -38,6 +39,7 @@ from cooperative_cuisine.utils import ( expand_path, add_study_arguments, deep_update, + UUID_CUTOFF, ) log = logging.getLogger(__name__) @@ -108,6 +110,9 @@ class Study: with open(study_config_path, "r") as file: env_config_f = file.read() + self.study_id = uuid.uuid4().hex[:UUID_CUTOFF] + """Unique ID of the study.""" + self.study_config: StudyConfig = yaml.load( str(env_config_f), Loader=yaml.Loader ) @@ -208,6 +213,7 @@ class Study: environment_config=environment_config, layout_config=layout, seed=seed, + env_name=f"study_{self.study_id}_level_{self.current_level_idx}", ).model_dump(mode="json") env_info = request_game_server( @@ -547,6 +553,7 @@ class StudyManager: environment_config=environment_config, layout_config=layout, seed=1234567890, + env_name="tutorial", ).model_dump(mode="json") # todo async env_info = request_game_server( @@ -556,7 +563,6 @@ class StudyManager: match env_info.status_code: case 200: env_info = env_info.json() - print("CREATE TUTORIAL:", env_info) study_manager.running_tutorials[participant_id] = env_info case 403: raise HTTPException( diff --git a/cooperative_cuisine/utils.py b/cooperative_cuisine/utils.py index 9314a01e29c0d7abb23c90323067280460f1057f..88719d54d8fc2fc00cf67d464af0a8c764f9457e 100644 --- a/cooperative_cuisine/utils.py +++ b/cooperative_cuisine/utils.py @@ -35,6 +35,9 @@ DEFAULT_SERVER_PORT = 8080 DEFAULT_GAME_PORT = 8000 """Default game server port.""" +UUID_CUTOFF = 8 +"""The cutoff length for UUIDs.""" + def expand_path(path: str, env_name: str = "") -> str: """Expand a path with VARIABLES to the path variables based on the user's OS or installation location of the Cooperative Cuisine. @@ -459,7 +462,6 @@ def create_layout_with_counters(w, h) -> str: else: string += "_" string += "\n" - print(string) return string diff --git a/cooperative_cuisine/validation.py b/cooperative_cuisine/validation.py index a76dc0335bb7b865514b97d3c1b61f4eaacb1a38..e6b615cb68126dcf457f5a7f5d6d534427bef75c 100644 --- a/cooperative_cuisine/validation.py +++ b/cooperative_cuisine/validation.py @@ -1,5 +1,6 @@ """ Validation of configs and tutorial/guide creation for recipes. """ - +import hashlib +import json import os import warnings from typing import TypedDict, Tuple, Iterator, Set @@ -61,6 +62,8 @@ class Validation: """For the available meals for orders.""" self.do_validation: bool = do_validation """A boolean indicating whether to perform validation tasks.""" + self.recipe_graph_dicts: dict | None = None + """A dictionary containing recipe graphs for each meal. For visualisation of the recipes.""" @staticmethod def infer_recipe_graph(item_info) -> DiGraph: @@ -95,7 +98,8 @@ class Validation: return graph def get_meal_graph(self, meal: ItemInfo) -> MealGraphDict: - """Create tutorial/guide graphs for each recipe/meal. + """Create tutorial/guide graphs for each recipe/meal. Created graphs are cached in a json file + because the creation of the graph layout is time-consuming. They are indexed by a hash of the graph edges. Args: meal: An instance of ItemInfo representing the meal to create a graph for. @@ -155,11 +159,37 @@ class Validation: current, ) - return { - "meal": meal.name, - "edges": list(graph.edges), - "layout": nx.nx_agraph.graphviz_layout(graph, prog="dot"), - } + before_hash = "".join(sorted(str(sorted([sorted(i) for i in graph.edges])))) + h = hashlib.new("sha1") + h.update(before_hash.encode()) + graph_hash = h.hexdigest() + generated_graph_layouts_path = ( + ROOT_DIR / "generated" / "recipe_graph_layouts.json" + ) + if self.recipe_graph_dicts is None: + if not os.path.exists(generated_graph_layouts_path.parent): + os.makedirs(generated_graph_layouts_path.parent) + if os.path.exists(generated_graph_layouts_path): + with open(generated_graph_layouts_path, "r") as f: + self.recipe_graph_dicts = json.loads(f.read()) + else: + self.recipe_graph_dicts = {} + + if graph_hash in self.recipe_graph_dicts.keys(): + graph_dict = self.recipe_graph_dicts[graph_hash] + return graph_dict + else: + layout = nx.nx_agraph.graphviz_layout(graph, prog="dot") + graph_dict = { + "meal": meal.name, + "edges": list(graph.edges), + "layout": layout, + } + with open(generated_graph_layouts_path, "w") as f: + self.recipe_graph_dicts[graph_hash] = graph_dict + f.write(json.dumps(self.recipe_graph_dicts, indent=4)) + + return graph_dict def reduce_item_node(self, graph, base_ingredients, item, visited): # until now not called diff --git a/tests/test_start.py b/tests/test_start.py index 4bb89d7dc54f02f4f091dac84b271b2aa817b420..6fe0c0187b494a7a6bc2c24ef432ab1c5ca8b5e9 100644 --- a/tests/test_start.py +++ b/tests/test_start.py @@ -43,8 +43,11 @@ from cooperative_cuisine.utils import create_init_env_time, get_touching_counter layouts_folder = ROOT_DIR / "configs" / "layouts" environment_config_path = ROOT_DIR / "configs" / "environment_config.yaml" +environment_config_no_validation_path = ( + ROOT_DIR / "configs" / "environment_config_no_validation.yaml" +) layout_path = ROOT_DIR / "configs" / "layouts" / "basic.layout" -layout_empty_path = ROOT_DIR / "configs" / "layouts" / "basic.layout" +layout_empty_path = ROOT_DIR / "configs" / "layouts" / "empty.layout" item_info_path = ROOT_DIR / "configs" / "item_info.yaml" # TODO: TESTs are in absolute pixel coordinates still. @@ -54,6 +57,9 @@ item_info_path = ROOT_DIR / "configs" / "item_info.yaml" def test_file_availability(): assert layouts_folder.is_dir(), "layouts folder does not exists" assert environment_config_path.is_file(), "environment config file does not exists" + assert ( + environment_config_no_validation_path.is_file() + ), "environment config file does not exists" assert layout_path.is_file(), "layout config file does not exists" assert layout_empty_path.is_file(), "layout empty config file does not exists" assert item_info_path.is_file(), "item info config file does not exists" @@ -67,6 +73,13 @@ def env_config(): return env_config +@pytest.fixture +def env_config_no_validation(): + with open(environment_config_no_validation_path, "r") as file: + env_config = file.read() + return env_config + + @pytest.fixture def layout_config(): with open(layout_path, "r") as file: @@ -76,7 +89,7 @@ def layout_config(): @pytest.fixture def layout_empty_config(): - with open(layout_path, "r") as file: + with open(layout_empty_path, "r") as file: layout = file.read() return layout @@ -101,8 +114,10 @@ def test_player_registration(env_config, layout_config, item_info): env.add_player("2") -def test_movement(env_config, layout_empty_config, item_info): - env = Environment(env_config, layout_empty_config, item_info, as_files=False) +def test_movement(env_config_no_validation, layout_empty_config, item_info): + env = Environment( + env_config_no_validation, layout_empty_config, item_info, as_files=False + ) player_name = "1" start_pos = np.array([3, 4]) env.add_player(player_name, start_pos) @@ -122,8 +137,12 @@ def test_movement(env_config, layout_empty_config, item_info): ), "Performed movement do not move the player as expected." -def test_player_movement_speed(env_config, layout_empty_config, item_info): - env = Environment(env_config, layout_empty_config, item_info, as_files=False) +def test_player_movement_speed( + env_config_no_validation, layout_empty_config, item_info +): + env = Environment( + env_config_no_validation, layout_empty_config, item_info, as_files=False + ) player_name = "1" start_pos = np.array([3, 4]) env.add_player(player_name, start_pos) @@ -148,8 +167,10 @@ def test_player_movement_speed(env_config, layout_empty_config, item_info): ), "json state does not match expected StateRepresentation." -def test_player_reach(env_config, layout_empty_config, item_info): - env = Environment(env_config, layout_empty_config, item_info, as_files=False) +def test_player_reach(env_config_no_validation, layout_empty_config, item_info): + env = Environment( + env_config_no_validation, layout_empty_config, item_info, as_files=False + ) counter_pos = np.array([2, 2]) counter = Counter(pos=counter_pos, hook=Hooks(env)) diff --git a/tests/test_utils.py b/tests/test_utils.py index ad9fb6b67b7af5a3a281072cfe59ea838ae9c38e..2802bb3a73f7deea7d1ee169adc0eb132b399096 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,15 @@ +import json from argparse import ArgumentParser +import networkx +import pytest + +from cooperative_cuisine.environment import Environment +from cooperative_cuisine.state_representation import ( + create_movement_graph, + restrict_movement_graph, + astar_heuristic, +) from cooperative_cuisine.utils import ( url_and_port_arguments, add_list_of_manager_ids_arguments, @@ -9,6 +19,8 @@ from cooperative_cuisine.utils import ( create_layout_with_counters, setup_logging, ) +from tests.test_start import env_config_no_validation +from tests.test_start import layout_empty_config, item_info def test_parser_gen(): @@ -44,3 +56,92 @@ def test_layout_creation(): def test_setup_logging(): setup_logging() + + +def test_movement_graph(env_config_no_validation, layout_empty_config, item_info): + env = Environment( + env_config_no_validation, layout_empty_config, item_info, as_files=False + ) + player_name = "0" + env.add_player(player_name) + + state_string = env.get_json_state(player_id=player_name) + state = json.loads(state_string) + graph_diag = create_movement_graph(state, diagonal=True) + + graph = create_movement_graph( + json.loads(env.get_json_state(player_id=player_name)), diagonal=False + ) + path = networkx.astar_path( + graph, + source=(0, 0), + target=(3, 3), + heuristic=astar_heuristic, + ) + assert len(path) != 0, "No path found, but should have." + + graph_restricted = restrict_movement_graph(graph_diag, [(1, 0), (0, 1), (1, 1)]) + with pytest.raises(networkx.exception.NetworkXNoPath) as e_info: + path = networkx.astar_path( + graph_restricted, + source=(0, 0), + target=(3, 3), + heuristic=astar_heuristic, + ) + with pytest.raises(networkx.exception.NodeNotFound) as e_info: + path = networkx.astar_path( + graph_restricted, + source=(20, 20), + target=(40, 40), + heuristic=astar_heuristic, + ) + + path = networkx.astar_path( + restrict_movement_graph( + graph=graph_diag, + player_positions=[], + ), + source=(0, 0), + target=(5, 5), + heuristic=astar_heuristic, + ) + assert len(path) != 0, "No path found, but should have." + + # now with diagonal movement + graph = create_movement_graph( + json.loads(env.get_json_state(player_id=player_name)), diagonal=True + ) + path = networkx.astar_path( + graph, + source=(0, 0), + target=(3, 3), + heuristic=astar_heuristic, + ) + assert len(path) != 0, "No path found, but should have." + + graph_restricted = restrict_movement_graph(graph_diag, [(1, 0), (0, 1), (1, 1)]) + with pytest.raises(networkx.exception.NetworkXNoPath) as e_info: + path = networkx.astar_path( + graph_restricted, + source=(0, 0), + target=(3, 3), + heuristic=astar_heuristic, + ) + with pytest.raises(networkx.exception.NodeNotFound) as e_info: + path = networkx.astar_path( + graph_restricted, + source=(20, 20), + target=(40, 40), + heuristic=astar_heuristic, + ) + + path = networkx.astar_path( + restrict_movement_graph( + graph=graph_diag, + player_positions=[], + ), + source=(0, 0), + target=(5, 5), + heuristic=astar_heuristic, + ) + assert len(path) != 0, "No path found, but should have."