diff --git a/cooperative_cuisine/counters.py b/cooperative_cuisine/counters.py index 01421b2137f238b8413eee3194a227c2c87e8576..cd84be94e982e3c6e49ecefd501dbe075be84237 100644 --- a/cooperative_cuisine/counters.py +++ b/cooperative_cuisine/counters.py @@ -684,10 +684,10 @@ class PlateDispenser(Counter): def progress(self, passed_time: timedelta, now: datetime): """Check if plates arrive from outside the kitchen and add a dirty plate accordingly""" - if self.next_plate_time < now: + if self.next_plate_time <= now: idx_delete = [] for i, times in enumerate(self.out_of_kitchen_timer): - if times < now: + if times <= now: self.hook(DIRTY_PLATE_ARRIVES, counter=self, times=times, now=now) idx_delete.append(i) log.debug("Add dirty plate") diff --git a/cooperative_cuisine/effects.py b/cooperative_cuisine/effects.py index 3688ce649ef10ddbddb1671709a75db41f5a1fe6..53989c0916dd9d8f2fe82b44ef2be4f971e1da29 100644 --- a/cooperative_cuisine/effects.py +++ b/cooperative_cuisine/effects.py @@ -163,9 +163,9 @@ class FireEffectManager(EffectManager): self.active_effects.append((effect, target)) # reset new effects self.new_effects = [] - if self.next_finished_timer < now: + if self.next_finished_timer <= now: for effect, target in self.active_effects: - if self.effect_to_timer[effect.uuid] < now: + if self.effect_to_timer[effect.uuid] <= now: if isinstance(target, Item): target = find_item_on_counters(target.uuid, self.counters) if target: diff --git a/cooperative_cuisine/info_msg.py b/cooperative_cuisine/info_msg.py index 7151ac0383b1653a5b8f89a62e5c806ec52767f0..d8077ed06d116579c0265644e30a29dd1a2fdf5d 100644 --- a/cooperative_cuisine/info_msg.py +++ b/cooperative_cuisine/info_msg.py @@ -91,7 +91,7 @@ class InfoMsgManager(HookCallbackClass): for player_id, msgs in env.info_msgs_per_player.items(): delete_msgs = [] for idx, msg in enumerate(msgs): - if msg["end_time"] < env.env_time: + if msg["end_time"] <= env.env_time: delete_msgs.append(idx) for idx in reversed(delete_msgs): msgs.pop(idx) diff --git a/cooperative_cuisine/pygame_2d_vis/drawing.py b/cooperative_cuisine/pygame_2d_vis/drawing.py index f9a994625856c4ee3a2bee92e208c0df7b6a6d3c..0c67eaf1549e8e45510832cc5c9296591883715c 100644 --- a/cooperative_cuisine/pygame_2d_vis/drawing.py +++ b/cooperative_cuisine/pygame_2d_vis/drawing.py @@ -2,6 +2,7 @@ import argparse import colorsys import json import os +import sys from datetime import datetime, timedelta from pathlib import Path @@ -1002,7 +1003,21 @@ def generate_recipe_images(config: dict, folder_path: str | Path): pygame.image.save(screen, f"{folder_path}/{graph_dict['meal']}.png") -if __name__ == "__main__": +def main(args): + """ + + Runs the Cooperative Cuisine Image Generation process. + + This method takes command line arguments to specify the state file, visualization configuration file, and output file for the generated image. It then reads the visualization configuration + * file and state file, and calls the 'save_screenshot' and 'generate_recipe_images' methods to generate the image. + + Args: + -s, --state: A command line argument of type `argparse.FileType("r", encoding="UTF-8")`. Specifies the state file to use for image generation. If not provided, the default value is 'ROOT_DIR / "pygame_2d_vis" / "sample_state.json"'. + + -v, --visualization_config: A command line argument of type `argparse.FileType("r", encoding="UTF-8")`. Specifies the visualization configuration file to use for image generation. If not provided, the default value is 'ROOT_DIR / "pygame_2d_vis" / "visualization.yaml"'. + + -o, --output_file: A command line argument of type `str`. Specifies the output file path for the generated image. If not provided, the default value is 'ROOT_DIR / "generated" / "screenshot.jpg"'. + """ parser = argparse.ArgumentParser( prog="Cooperative Cuisine Image Generation", description="Generate images for a state in json.", @@ -1024,12 +1039,16 @@ if __name__ == "__main__": "-o", "--output_file", type=str, - default=ROOT_DIR / "pygame_2d_vis" / "generated" / "screenshot.jpg", + default=ROOT_DIR / "generated" / "screenshot.jpg", ) - args = parser.parse_args() + args = parser.parse_args(args) with open(args.visualization_config, "r") as f: viz_config = yaml.safe_load(f) with open(args.state, "r") as f: state = json.load(f) save_screenshot(state, viz_config, args.output_file) generate_recipe_images(viz_config, args.output_file.parent) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/cooperative_cuisine/scores.py b/cooperative_cuisine/scores.py index c0cc1b217d5b3662d2fecf0cdf6e64a0ebc964ee..3bab87e92f29caec0903c3865c747757f3b0c188 100644 --- a/cooperative_cuisine/scores.py +++ b/cooperative_cuisine/scores.py @@ -86,13 +86,13 @@ class ScoreViaHooks(HookCallbackClass): **kwargs: Additional keyword arguments to be passed to the parent class. """ super().__init__(name, env, **kwargs) - self.score_map = score_map + self.score_map: dict[str, float] = score_map """Mapping of hook references to scores.""" - self.static_score = static_score + self.static_score: float = static_score """The static score to be added if no other conditions are met.""" - self.kwarg_filter = kwarg_filter + self.kwarg_filter: dict[str, Any] = kwarg_filter """Filtering condition for keyword arguments.""" - self.score_on_specific_kwarg = score_on_specific_kwarg + self.score_on_specific_kwarg: str = score_on_specific_kwarg """The specific keyword argument to score on.""" def __call__(self, hook_ref: str, env: Environment, **kwargs): @@ -106,7 +106,7 @@ class ScoreViaHooks(HookCallbackClass): self.env.increment_score(self.static_score, info=hook_ref) elif self.score_map and hook_ref in self.score_map: if self.kwarg_filter: - if kwargs.items() <= self.kwarg_filter.items(): + if self.kwarg_filter.items() <= kwargs.items(): self.env.increment_score( self.score_map[hook_ref], info=f"{hook_ref} - {self.kwarg_filter}", diff --git a/tests/test_game_server.py b/tests/test_game_server.py index f072fed2dd5413ad016e64406146262c93723e53..84271ad77196e85aef512ce9994e0d8926130a0e 100644 --- a/tests/test_game_server.py +++ b/tests/test_game_server.py @@ -282,3 +282,10 @@ def test_websocket_wrong_inputs(create_env_config): finally: task.cancel() loop.close() + + +def test_root(): + with TestClient(app) as client: + res = client.get("/") + assert res.status_code == status.HTTP_200_OK + assert res.json() == {"Cooperative": "Cuisine"} diff --git a/tests/test_pygame.py b/tests/test_pygame.py new file mode 100644 index 0000000000000000000000000000000000000000..9df43e0813184f20a159001c91684f149bc17fbb --- /dev/null +++ b/tests/test_pygame.py @@ -0,0 +1,40 @@ +import json +import os + +import yaml + +from cooperative_cuisine import ROOT_DIR +from cooperative_cuisine.pygame_2d_vis.drawing import calc_angle, Visualizer, main +from cooperative_cuisine.pygame_2d_vis.game_colors import RGB, WHITE, colors + + +def test_colors(): + assert RGB(red=255, green=255, blue=255).hex_format() == "#FFFFFF" + assert WHITE.hex_format() == "#FFFFFF" + assert len(colors) >= 552 + + +def test_calc_angle(): + assert calc_angle([0.0, 1.0], [1.0, 0.0]) == -90.0 + assert calc_angle([1.0, 1.0], [1.0, -1.0]) == -90.0 + + +def test_drawing(): + main([]) + assert os.path.exists(os.path.join(ROOT_DIR, "generated", "screenshot.jpg")) + + +def test_visualizer(): + with open(ROOT_DIR / "pygame_2d_vis" / "visualization.yaml", "r") as file: + visualization_config = yaml.safe_load(file) + + vis = Visualizer(visualization_config) + + vis.create_player_colors(10) + assert len(vis.player_colors) >= 10 + assert len(set(vis.player_colors)) >= 10 + + with open(ROOT_DIR / "pygame_2d_vis" / "sample_state.json", "r") as file: + state = json.load(file) + image = vis.get_state_image(40, state) + assert image.shape == (480, 360, 3) diff --git a/tests/test_start.py b/tests/test_start.py index fec31349ac50c39f3d02019c66f1a7f802389a33..2d509cc16af5d517449094397c7d70f59314d061 100644 --- a/tests/test_start.py +++ b/tests/test_start.py @@ -1,17 +1,36 @@ +from collections import deque from datetime import timedelta import numpy as np import pytest +import yaml from cooperative_cuisine import ROOT_DIR from cooperative_cuisine.action import ActionType, InterActionData, Action -from cooperative_cuisine.counters import Counter, CuttingBoard +from cooperative_cuisine.counters import ( + Counter, + CuttingBoard, + CookingCounter, + ServingWindow, + Trashcan, + PlateDispenser, + Sink, +) +from cooperative_cuisine.effects import FireEffectManager from cooperative_cuisine.environment import ( Environment, ) from cooperative_cuisine.game_server import PlayerRequestType -from cooperative_cuisine.hooks import Hooks -from cooperative_cuisine.items import Item, ItemInfo, ItemType +from cooperative_cuisine.hooks import ( + Hooks, + SERVE_NOT_ORDERED_MEAL, + hooks_via_callback_class, + PLAYER_ADDED, + POST_STEP, +) +from cooperative_cuisine.info_msg import InfoMsgManager +from cooperative_cuisine.items import Item, ItemInfo, ItemType, Plate, CookingEquipment +from cooperative_cuisine.scores import ScoreViaHooks from cooperative_cuisine.server_results import ( PlayerInfo, CreateEnvResult, @@ -21,7 +40,7 @@ from cooperative_cuisine.state_representation import ( StateRepresentation, create_json_schema, ) -from cooperative_cuisine.utils import create_init_env_time +from cooperative_cuisine.utils import create_init_env_time, get_touching_counters layouts_folder = ROOT_DIR / "configs" / "layouts" environment_config_path = ROOT_DIR / "configs" / "environment_config.yaml" @@ -54,7 +73,6 @@ def layout_config(): with open(layout_path, "r") as file: layout = file.read() return layout - env.add_player("0") @pytest.fixture @@ -218,6 +236,7 @@ def test_processing(env_config, layout_config, item_info): }, ) env.counters.append(counter) + env.overwrite_counters(env.counters) tomato = Item(name="Tomato", item_info=None) env.add_player("1", np.array([2, 3])) @@ -308,3 +327,360 @@ def test_server_result_definition(): msg="123", player_hash="1234324", ) + + +def test_fire(env_config, layout_config, item_info): + env = Environment(env_config, layout_config, item_info, as_files=False) + env.add_player("0") + oven = None + for c in env.counters: + if ( + isinstance(c, CookingCounter) + and c.name == "Stove" + and c.occupied_by.name == "Pan" + ): + oven = c + break + assert oven is not None + + raw_patty = Item(name="RawPatty", item_info=env.item_info["RawPatty"]) + + assert oven.can_drop_off(raw_patty) + + oven.drop_off(raw_patty, "0") + assert isinstance(oven.occupied_by, CookingEquipment) + assert oven.occupied_by.content_list == [raw_patty] + + env.step(timedelta(seconds=env.item_info["CookedPatty"].seconds)) + assert oven.occupied_by.content_list[0].name == "CookedPatty" + env.step(timedelta(seconds=env.item_info["BurntCookedPatty"].seconds)) + assert oven.occupied_by.content_list[0].name == "BurntCookedPatty" + env.step(timedelta(seconds=env.item_info["Fire"].seconds)) + assert len(oven.occupied_by.active_effects) != 0 + assert oven.occupied_by.active_effects[0].name == "Fire" + + fire_manager = env.effect_manager["FireManager"] + assert isinstance(fire_manager, FireEffectManager) + env.step(fire_manager.next_finished_timer - env.env_time) + + touching_counters = get_touching_counters(oven, env.counters) + next_empty = None + connect_counter = None + for c in touching_counters: + if c.occupied_by: + assert len(c.occupied_by.active_effects) == 1 + else: + assert len(c.active_effects) == 1 + next_touching = get_touching_counters(c, env.counters) + for a in next_touching: + if a not in touching_counters and a.__class__.__name__ == "Counter": + a.occupied_by = None + next_empty = a + connect_counter = c + env.step(timedelta(seconds=0.01)) + assert next_empty is not None + next_empty.occupied_by = Item(name="Tomato", item_info=env.item_info["Tomato"]) + env.step( + fire_manager.effect_to_timer[connect_counter.active_effects[0].uuid] + - env.env_time + ) + assert len(next_empty.occupied_by.active_effects) == 1 + + fire_extinguisher = None + for c in env.counters: + if c.occupied_by and c.occupied_by.name == "Extinguisher": + fire_extinguisher = c.occupied_by + c.occupied_by = None + break + + assert fire_extinguisher is not None + env.players["0"].holding = fire_extinguisher + env.players["0"].pos = oven.pos + env.players["0"].pos[1] += 1.0 + env.perform_action( + Action( + player="0", + action_type=ActionType.MOVEMENT, + action_data=np.array([0.0, -1.0]), + duration=0.1, + ) + ) + env.step(timedelta(seconds=0.1)) + + env.perform_action( + Action( + player="0", + action_type=ActionType.INTERACT, + action_data=InterActionData.START, + ) + ) + env.step(timedelta(seconds=env.item_info["Extinguisher"].seconds)) + env.step(timedelta(seconds=env.item_info["Extinguisher"].seconds)) + + assert len(oven.occupied_by.active_effects) == 0 + + +def test_score(env_config, layout_config, item_info): + def incr_score_callback(hook_ref, env: Environment, meal, meal_name, **kwargs): + assert isinstance(meal, Item) + assert isinstance(meal_name, str) + assert meal_name == "TomatoSoup" + env.increment_score(1_000, "Soup Soup") + + env = Environment(env_config, layout_config, item_info, as_files=False) + assert env.score == 0.0 + env.add_player("0") + env.register_callback_for_hook(SERVE_NOT_ORDERED_MEAL, incr_score_callback) + env.register_callback_for_hook( + SERVE_NOT_ORDERED_MEAL, + ScoreViaHooks( + name="123", + env=env, + score_on_specific_kwarg="meal_name", + score_map={"TomatoSoup": 2_000}, + ), + ) + env.register_callback_for_hook( + SERVE_NOT_ORDERED_MEAL, + ScoreViaHooks(name="124", env=env, score_map={SERVE_NOT_ORDERED_MEAL: 4_000}), + ) + env.register_callback_for_hook( + SERVE_NOT_ORDERED_MEAL, + ScoreViaHooks( + name="124", + env=env, + score_map={SERVE_NOT_ORDERED_MEAL: 8_000}, + kwarg_filter={"meal_name": "TomatoSoup"}, + ), + ) + env.register_callback_for_hook( + SERVE_NOT_ORDERED_MEAL, + ScoreViaHooks( + name="123", + env=env, + score_on_specific_kwarg="meal_name", + static_score=16_000, + score_map={}, + ), + ) + serving_window = None + for c in env.counters: + if isinstance(c, ServingWindow): + serving_window = c + break + assert serving_window is not None + env.order_manager.serving_not_ordered_meals = True + env.order_manager.open_orders = [] + plate = Plate( + transitions=env.counter_factory.filter_item_info( + by_item_type=ItemType.Meal, add_effects=True + ), + clean=True, + item_info=env.item_info["Plate"], + hook=env.hook, + ) + plate.content_list = [ + Item(name="TomatoSoup", item_info=env.item_info["TomatoSoup"]) + ] + assert serving_window.can_drop_off(plate) + + returned = serving_window.drop_off(plate, "0") + assert returned is None + + plates_prev = len(serving_window.plate_dispenser.occupied_by) + assert env.score >= 31_000 + assert len(serving_window.plate_dispenser.out_of_kitchen_timer) == 1 + env.step(serving_window.plate_dispenser.out_of_kitchen_timer[0] - env.env_time) + assert len(serving_window.plate_dispenser.out_of_kitchen_timer) == 0 + assert len(serving_window.plate_dispenser.occupied_by) == plates_prev + 1 + assert ( + serving_window.plate_dispenser.occupied_by[0].clean + != serving_window.plate_dispenser.plate_config.return_dirty + ) + + +def test_info_msgs(env_config, layout_config, item_info): + env_config_dict = yaml.load(env_config, Loader=yaml.Loader) + # TODO change after merge with 115 + env_config_dict["extra_setup_functions"]["dummy_msg"] = { + "func": hooks_via_callback_class, + "kwargs": { + "hooks": [PLAYER_ADDED], + "callback_class": InfoMsgManager, + "callback_class_kwargs": {"msg": "hello there"}, + }, + } + + env_config_dict["extra_setup_functions"]["dummy_msg_2"] = { + "func": hooks_via_callback_class, + "kwargs": { + "hooks": [POST_STEP], + "callback_class": InfoMsgManager, + "callback_class_kwargs": {"msg": "step step"}, + }, + } + + env_config = yaml.dump(env_config_dict) + + env = Environment(env_config, layout_config, item_info, as_files=False) + env.add_player("0") + assert env.info_msgs_per_player["0"][0]["msg"] == "hello there" + env.step(timedelta(seconds=0.1)) + assert env.info_msgs_per_player["0"][1]["msg"] == "step step" + env.step(env.info_msgs_per_player["0"][0]["end_time"] - env.env_time) + assert len(env.info_msgs_per_player["0"]) == 2 + assert env.info_msgs_per_player["0"][0]["msg"] == "step step" + + +def test_trashcan(env_config, layout_config, item_info): + env = Environment(env_config, layout_config, item_info, as_files=False) + env.add_player("0") + trash = None + for c in env.counters: + if isinstance(c, Trashcan): + trash = c + break + assert trash is not None + + item = Item(name="Tomato", item_info=env.item_info["Tomato"]) + assert trash.can_drop_off(item) + assert trash.drop_off(item, "0") is None + + plate = Plate( + transitions=env.counter_factory.filter_item_info( + by_item_type=ItemType.Meal, add_effects=True + ), + clean=True, + item_info=env.item_info["Plate"], + hook=env.hook, + ) + plate.content_list = [ + Item(name="TomatoSoup", item_info=env.item_info["TomatoSoup"]) + ] + + assert trash.can_drop_off(plate) + assert trash.drop_off(plate, "0") == plate + assert plate.content_list == [] + + assert trash.pick_up(True, "0") is None + + +def test_plate_dispenser(env_config, layout_config, item_info): + env = Environment(env_config, layout_config, item_info, as_files=False) + env.add_player("0") + plate_dis = None + for c in env.counters: + if isinstance(c, PlateDispenser): + plate_dis = c + break + + assert plate_dis is not None + assert ( + len(plate_dis.occupied_by) > 0 + ) # Otherwise adapt env_config above before passing to Environment + n_plates = len(plate_dis.occupied_by) + env = Environment(env_config, layout_config, item_info, as_files=False) + env.add_player("0") + item = Item(name="ChoppedTomato", item_info=env.item_info["ChoppedTomato"]) + assert plate_dis.can_drop_off(item) + returned = plate_dis.drop_off(item, "0") + assert returned is None + + assert len(plate_dis.occupied_by) == n_plates + + first_plate = plate_dis.pick_up(True, "0") + assert isinstance(first_plate, Plate) + assert ( + first_plate.content_list and first_plate.content_list[0].name == "ChoppedTomato" + ) + + assert plate_dis.can_drop_off(first_plate) + returned = plate_dis.drop_off(first_plate, "0") + assert isinstance(returned, Plate) and len(returned.content_list) == 0 + assert plate_dis.occupied_by[-1].content_list[0].name == "ChoppedTomato" + + plate_dis.occupied_by = deque() + assert plate_dis.can_drop_off(item) + returned = plate_dis.drop_off(item, "0") + assert returned is None + assert plate_dis.occupied_by[0].name == "ChoppedTomato" + + +def test_sink(env_config, layout_config, item_info): + env = Environment(env_config, layout_config, item_info, as_files=False) + env.add_player("0") + sink = None + for c in env.counters: + if isinstance(c, Sink): + sink = c + break + assert sink is not None + env.players["0"].pos = sink.pos + env.players["0"].pos[1] -= 1.0 + env.perform_action( + Action( + player="0", + action_type=ActionType.MOVEMENT, + action_data=np.array([0.0, 1.0]), + duration=0.1, + ) + ) + env.step(timedelta(seconds=0.1)) + + plate = Plate( + transitions=env.counter_factory.filter_item_info( + by_item_type=ItemType.Meal, add_effects=True + ), + clean=False, + item_info=env.item_info["Plate"], + hook=env.hook, + ) + plate_2 = Plate( + transitions=env.counter_factory.filter_item_info( + by_item_type=ItemType.Meal, add_effects=True + ), + clean=False, + item_info=env.item_info["Plate"], + hook=env.hook, + ) + clean_plate = Plate( + transitions=env.counter_factory.filter_item_info( + by_item_type=ItemType.Meal, add_effects=True + ), + clean=True, + item_info=env.item_info["Plate"], + hook=env.hook, + ) + + assert sink.can_drop_off(plate) + assert sink.drop_off(plate, "0") is None + assert sink.can_drop_off(plate_2) + assert sink.drop_off(plate_2, "0") is None + assert not sink.can_drop_off(clean_plate) + + assert len(sink.occupied_by) == 2 + + env.perform_action( + Action( + player="0", + action_type=ActionType.INTERACT, + action_data=InterActionData.START, + ) + ) + env.step(timedelta(seconds=env.item_info["Plate"].seconds)) + assert len(sink.occupied_by) == 1 + assert len(sink.sink_addon.occupied_by) == 1 + assert sink.sink_addon.occupied_by[0].clean + assert not sink.occupied_by[0].clean + assert sink.pick_up(True, "0") is None + env.step(timedelta(seconds=env.item_info["Plate"].seconds)) + assert len(sink.occupied_by) == 0 + assert len(sink.sink_addon.occupied_by) == 2 + assert sink.sink_addon.occupied_by[0].clean + assert sink.sink_addon.occupied_by[1].clean + + item = Item(name="ChoppedTomato", item_info=env.item_info["ChoppedTomato"]) + assert sink.sink_addon.can_drop_off(item) + assert sink.sink_addon.drop_off(item, "0") is None + assert sink.sink_addon.pick_up(True, "0").content_list[0].name == "ChoppedTomato" + assert len(sink.sink_addon.occupied_by) == 1 diff --git a/tests/test_study_server.py b/tests/test_study_server.py index 0d979633495d970274dad50e005dfa61f4513fcc..20776e60b190ee93d4bceec0c89e38c35deb531e 100644 --- a/tests/test_study_server.py +++ b/tests/test_study_server.py @@ -92,4 +92,40 @@ def test_game_server_crashed(): assert res.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR +def test_tutorial(): + test_response = Response() + test_response.status_code = status.HTTP_200_OK + test_response.encoding = "utf8" + test_response._content = json.dumps( + { + "player_info": { + "0": { + "player_id": "0", + "client_id": "ksjdhfkjsdfn", + "player_hash": "shdfbmsndfb", + } + }, + "env_id": "123456789", + "recipe_graphs": [], + } + ).encode() + with mock.patch.object( + study_server_module, "request_game_server", return_value=test_response + ) as mock_call: + with TestClient(app) as client: + res = client.post("/connect_to_tutorial/124") + + assert res.status_code == status.HTTP_200_OK + + mock_call.assert_called_once() + + with mock.patch.object( + study_server_module, "request_game_server", return_value=test_response + ) as mock_call: + with TestClient(app) as client: + res = client.post("/disconnect_from_tutorial/124") + + assert res.status_code == status.HTTP_200_OK + + # TOOD test bots