From ad544b0a5bea2f477700f456112b43118007bae3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Florian=20Schr=C3=B6der?=
 <fschroeder@techfak.uni-bielefeld.de>
Date: Thu, 14 Dec 2023 19:01:23 +0100
Subject: [PATCH] PlateReturn is now PlateDispenser (just a Dispenser), Changed
 path concatenation, Created dataclass for item information, pass item config
 path via argument of the environment, no tomato class anymore, no pot class
 anymore, no soup and pan class, now everything is either a meal,
 cuttableitem, item, cookingequipment or plate. maybe do the plate as a
 cooking equipment next.

---
 overcooked_simulator/counters.py              |  81 ++-------
 .../game_content/item_combinations.yaml       |  26 ---
 .../game_content/item_info.yaml               |  37 ++++
 overcooked_simulator/game_items.py            | 167 +++++++++++-------
 overcooked_simulator/main.py                  |   3 +-
 .../overcooked_environment.py                 |  32 +++-
 overcooked_simulator/pygame_gui/pygame_gui.py |  41 ++---
 .../pygame_gui/visualization.yaml             |   4 +-
 overcooked_simulator/simulation_runner.py     |  10 +-
 tests/test_start.py                           |   3 +-
 10 files changed, 215 insertions(+), 189 deletions(-)
 delete mode 100644 overcooked_simulator/game_content/item_combinations.yaml
 create mode 100644 overcooked_simulator/game_content/item_info.yaml

diff --git a/overcooked_simulator/counters.py b/overcooked_simulator/counters.py
index ed6bab57..3f68bae6 100644
--- a/overcooked_simulator/counters.py
+++ b/overcooked_simulator/counters.py
@@ -12,11 +12,9 @@ from overcooked_simulator.game_items import (
     CuttableItem,
     Item,
     Plate,
-    Pot,
     CookingEquipment,
-    Soup,
+    Meal,
 )
-from overcooked_simulator.game_items import item_loopkup
 
 
 class Counter:
@@ -128,7 +126,7 @@ class ServingWindow(Counter):
 
     def can_score(self, item):
         if isinstance(item, Plate) and item.holds is not None:
-            if isinstance(item.holds, Soup):
+            if isinstance(item.holds, Meal):
                 return item.holds.finished
             elif item.holds.name == "Salad":
                 return True
@@ -141,79 +139,31 @@ class ServingWindow(Counter):
         pass
 
 
-class PlateReturn(Counter):
-    def __init__(self, pos):
-        super().__init__(pos)
-        self.occupied_by = [Plate()]
-
-    def pick_up(self, on_hands: bool = True):
-        """Gets called upon a player performing the pickup action. Gives back a plate (possibly with ingredient.
-
-        Returns: A plate possibly with an ingredient on it.
-
-        """
-        give_player = self.occupied_by.pop()
-        if not self.occupied_by:
-            self.occupied_by.append(Plate())
-        return give_player
-
-    def drop_off(self, item: Item) -> Item | None:
-        """Takes the ingredient dropped of by the player.
-
-        Args:
-            item: The ingredient to be placed on the counter.
-        """
-        if isinstance(item, Plate):
-            if self.occupied_by[-1].holds:
-                return item
-            self.occupied_by.append(item)
-            return None
-        if self.occupied_by[-1].can_combine(item):
-            return self.occupied_by[-1].combine(item)
-        return item
-
-    def can_drop_off(self, item: Item) -> bool:
-        """Checks whether an ingredient by the player can be dropped of.
-
-        Args:
-            item: The ingredient for which to check, if it can be placed on the counter.
-
-        Returns: True if the ingredient can be placed on the counter, False if not.
-
-        """
-        # possibility to drop off empty plate on empty plate return
-        return (
-            isinstance(self.occupied_by[-1], Plate) and isinstance(item, Plate)
-        ) or self.occupied_by[-1].can_combine(item)
-
-
 class Dispenser(Counter):
     def __init__(self, pos, dispensing):
         self.dispensing = dispensing
         super().__init__(
             pos,
-            CuttableItem(
-                name=self.dispensing,
-                finished_name=item_loopkup[self.dispensing]["finished_name"],
-            ),
+            self.dispensing.create_item(),
         )
 
     def pick_up(self, on_hands: bool = True):
-        returned = CuttableItem(
-            name=self.dispensing,
-            finished_name=item_loopkup[self.dispensing]["finished_name"],
-        )
-        print(self.occupied_by)
-        return returned
+        new_dispensing = self.dispensing.create_item()
+        if self.occupied_by != new_dispensing:
+            old_dispensing = self.occupied_by
+            self.occupied_by = new_dispensing
+            return old_dispensing
+        return new_dispensing
 
     def drop_off(self, item: Item) -> Item | None:
-        return None
+        if self.occupied_by.can_combine(item):
+            return self.occupied_by.combine(item)
 
     def can_drop_off(self, item: Item) -> bool:
-        return False
+        return self.occupied_by.can_combine(item)
 
     def __repr__(self):
-        return f"{self.dispensing}Dispenser"
+        return f"{self.dispensing.name}Dispenser"
 
 
 class Trash(Counter):
@@ -234,11 +184,6 @@ class Trash(Counter):
 
 
 class Stove(Counter):
-    def __init__(self, pos: npt.NDArray[float], occupied_by: Optional[Item] = ...):
-        if occupied_by is ...:
-            occupied_by = Pot()
-        super().__init__(pos, occupied_by)
-
     def progress(self):
         """Called by environment step function for time progression"""
         if (
diff --git a/overcooked_simulator/game_content/item_combinations.yaml b/overcooked_simulator/game_content/item_combinations.yaml
deleted file mode 100644
index dd20f891..00000000
--- a/overcooked_simulator/game_content/item_combinations.yaml
+++ /dev/null
@@ -1,26 +0,0 @@
-Tomato:
-  type: Cuttable
-  needs: Tomato
-  finished_name: ChoppedTomato
-
-Lettuce:
-  type: Cuttable
-  needs: Lettuce
-  finished_name: ChoppedLettuce
-
-RawPatty:
-  type: Cuttable
-  needs: RawSteak
-
-Burger:
-  type: Meal
-  needs: [ Bun, ChoppedLettuce, ChoppedTomato, CookedPatty ]
-
-Salad:
-  type: Meal
-  needs: [ Lettuce, Tomato ]
-
-TomatoSoup:
-  type: Soup
-  needs: [ Tomato, Tomato, Tomato ]
-
diff --git a/overcooked_simulator/game_content/item_info.yaml b/overcooked_simulator/game_content/item_info.yaml
new file mode 100644
index 00000000..a2df2608
--- /dev/null
+++ b/overcooked_simulator/game_content/item_info.yaml
@@ -0,0 +1,37 @@
+Tomato:
+  type: Ingredient
+  needs: Tomato
+  is_cuttable: True
+  steps_needed: 500
+
+Lettuce:
+  type: Ingredient
+  needs: Lettuce
+  is_cuttable: True
+  steps_needed: 500
+
+RawPatty:
+  type: Ingredient
+  needs: RawSteak
+
+Burger:
+  type: Meal
+  needs: [ Bun, ChoppedLettuce, ChoppedTomato, CookedPatty ]
+
+Salad:
+  type: Meal
+  needs: [ Lettuce, Tomato ]
+  equipment: Plate
+
+TomatoSoup:
+  type: Meal
+  finished_progress_name: TomatoSoup
+  steps_needed: 500
+  needs: [ Tomato, Tomato, Tomato ]
+  equipment: Pot
+
+Plate:
+  type: Equipment
+
+Pot:
+  type: Equipment
diff --git a/overcooked_simulator/game_items.py b/overcooked_simulator/game_items.py
index 822ce543..a0356ccc 100644
--- a/overcooked_simulator/game_items.py
+++ b/overcooked_simulator/game_items.py
@@ -1,25 +1,85 @@
 from __future__ import annotations
 
-import yaml
-
-from overcooked_simulator import ROOT_DIR
-
-with open(ROOT_DIR / "game_content/item_combinations.yaml", "r") as file:
-    item_loopkup = yaml.safe_load(file)
+import dataclasses
+from enum import Enum
+
+
+class ItemType(Enum):
+    Ingredient = "Ingredient"
+    Meal = "Meal"
+    Equipment = "Equipment"
+
+
+@dataclasses.dataclass
+class ItemInfo:
+    type: ItemType = dataclasses.field(compare=False)
+    name: str = dataclasses.field(compare=True)
+    is_cuttable: bool = dataclasses.field(compare=False, default=False)
+    steps_needed: int = dataclasses.field(compare=False, default=0)
+    finished_progress_name: str = dataclasses.field(compare=False, default="Chopped*")
+    needs: list[ItemInfo] = dataclasses.field(compare=False, default_factory=list)
+    equipment: ItemInfo | None = dataclasses.field(compare=False, default=None)
+
+    _start_items: list[ItemInfo] = dataclasses.field(
+        compare=False, default_factory=list
+    )
+
+    def __post_init__(self):
+        self.type = ItemType(self.type)
+
+    def get_finished_name(self):
+        if "*" in self.finished_progress_name:
+            return self.finished_progress_name.replace("*", self.name)
+        return self.name
+
+    def create_item(self) -> Item:
+        match self.type:
+            case ItemType.Ingredient:
+                if self.is_cuttable:
+                    return CuttableItem(
+                        name=self.name,
+                        finished=False,
+                        steps_needed=self.steps_needed,
+                        finished_name=self.get_finished_name(),
+                        item_info=self,
+                    )
+                return Item(name=self.name, item_info=self)
+            case ItemType.Equipment:
+                if self.name == "Plate":
+                    return Plate(item_info=self)
+                if self.name == "Pot":
+                    return CookingEquipment(name=self.name, item_info=self)
+            case ItemType.Meal:
+                return Meal(
+                    name=self.name,
+                    finished=False,
+                    steps_needed=self.steps_needed,
+                    finished_name=self.get_finished_name(),
+                    item_info=self,
+                )
+
+    def add_start_item_to_equipment(self, start_item: ItemInfo):
+        self._start_items.append(start_item)
+
+    def can_start_meal(self, start_item: Item):
+        # TODO check specific order / only specific start items
+        return any(
+            [start_item.name == s for meal in self._start_items for s in meal.needs]
+        )
 
-combinables = {}
-for key, item in item_loopkup.items():
-    if item["type"] in ["Meal", "Soup"]:
-        combinables[key] = item["needs"]
-print(combinables)
+    def start_meal(self, start_item: Item) -> Item:
+        for meal in self._start_items:
+            for s in meal.needs:
+                if s == start_item.name:
+                    return meal.create_item()
 
 
 class Item:
     """Base class for game items which can be held by a player."""
 
-    def __init__(self, name: str = None, *args, **kwargs):
-        super().__init__(*args, **kwargs)
+    def __init__(self, name: str, item_info: ItemInfo, *args, **kwargs):
         self.name = self.__class__.__name__ if name is None else name
+        self.item_info = item_info
 
     def can_combine(self, other):
         return False
@@ -30,14 +90,17 @@ class Item:
     def __repr__(self):
         return f"{self.name}({self.extra_repr})"
 
+    def __eq__(self, other):
+        return other and self.name == other.name
+
     @property
     def extra_repr(self):
         return ""
 
 
 class Plate(Item):
-    def __init__(self, holds: Item = None):
-        super().__init__()
+    def __init__(self, holds: Item = None, *args, **kwargs):
+        super().__init__(*args, name="Plate", **kwargs)
         self.clean = True
         self.holds = holds
 
@@ -54,6 +117,14 @@ class Plate(Item):
             return other
         self.holds = other
 
+    def __eq__(self, other):
+        return (
+            other
+            and self.name == other.name
+            and self.clean == other.clean
+            and self.holds == other.holds
+        )
+
     @property
     def extra_repr(self):
         return self.holds
@@ -106,18 +177,6 @@ class ProgressibleItem(Item):
 class CuttableItem(ProgressibleItem):
     """Class of item which can be processed by the cutting board."""
 
-    pass
-
-
-class Tomato(CuttableItem, Item):
-    """Item class representing a tomato. Can be cut on the cutting board"""
-
-    def can_combine(self, other):
-        return False
-
-    def __init__(self):
-        super().__init__(steps_needed=500)
-
 
 class CookingEquipment(Item):
     def __init__(self, content: Meal = None, *args, **kwargs):
@@ -127,13 +186,13 @@ class CookingEquipment(Item):
     def can_combine(self, other):
         if self.content is None:
             # TODO check other is start of a meal, create meal
-            return True
+            return self.item_info.can_start_meal(other)
         return self.content.can_combine(other)
 
     def combine(self, other):
         if not self.content:
             # find starting meal for other
-            self.content = Soup()
+            self.content = self.item_info.start_meal(other)
         self.content.combine(other)
 
     def can_progress(self, counter_type="Stove") -> bool:
@@ -163,46 +222,36 @@ class CookingEquipment(Item):
         return self.content
 
 
-class Pot(CookingEquipment):
-    def __init__(self, holds: Meal = None):
-        super().__init__()
-
-
-class Meal(Item):
+class Meal(ProgressibleItem):
     def __init__(self, parts=None, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self.parts = [] if parts is None else parts
         # self.rules ...
 
     def can_combine(self, other) -> bool:
-        return (
-            isinstance(other, Tomato)
-            and all([isinstance(o, other.__class__) for o in self.parts])
-            and len(self.parts) < 3
-        )  # rules
+        if other and not self.finished:
+            satisfied = [False for _ in range(len(self.parts))]
+            for n in self.item_info.needs:
+                for i, p in enumerate(self.parts):
+                    if not satisfied[i] and p.name == n:
+                        satisfied[i] = True
+                        break
+                else:
+                    if n == other.name:
+                        return True
+        return False
 
     def combine(self, other):
         self.parts.append(other)
 
-    @property
-    def extra_repr(self):
-        return self.parts
-
-
-class Soup(ProgressibleItem, Meal):
-    def __init__(self):
-        super().__init__(finished_name="CookedSoup")
-
     def can_progress(self) -> bool:
-        return len(self.parts) == 3
-
-
-class Pan(CookingEquipment):
-    def __init__(self):
-        super().__init__(steps_needed=500)
+        return self.item_info.steps_needed and len(self.item_info.needs) == len(
+            self.parts
+        )
 
-    def can_combine(self, other):
-        return False
+    def finished_call(self):
+        super().finished_call()
 
-    def combine(self, other):
-        pass
+    @property
+    def extra_repr(self):
+        return self.parts
diff --git a/overcooked_simulator/main.py b/overcooked_simulator/main.py
index 9524338b..05d6f0c0 100644
--- a/overcooked_simulator/main.py
+++ b/overcooked_simulator/main.py
@@ -1,5 +1,4 @@
 import sys
-from pathlib import Path
 
 import numpy as np
 import pygame
@@ -11,7 +10,7 @@ from overcooked_simulator.simulation_runner import Simulator
 
 
 def main():
-    simulator = Simulator(Path(ROOT_DIR, "game_content/layouts", "basic.layout"), 600)
+    simulator = Simulator(ROOT_DIR / "game_content" / "layouts" / "basic.layout", 600)
     player_one_name = "p1"
     player_two_name = "p2"
     simulator.register_player(Player(player_one_name, np.array([350.0, 200.0])))
diff --git a/overcooked_simulator/overcooked_environment.py b/overcooked_simulator/overcooked_environment.py
index 4161e1d4..23ecb418 100644
--- a/overcooked_simulator/overcooked_environment.py
+++ b/overcooked_simulator/overcooked_environment.py
@@ -2,6 +2,10 @@ from __future__ import annotations
 
 from typing import TYPE_CHECKING
 
+import yaml
+
+from overcooked_simulator.game_items import ItemInfo
+
 if TYPE_CHECKING:
     from overcooked_simulator.player import Player
 from pathlib import Path
@@ -14,7 +18,6 @@ from overcooked_simulator.counters import (
     Trash,
     Dispenser,
     ServingWindow,
-    PlateReturn,
     Stove,
 )
 
@@ -55,10 +58,12 @@ class Environment:
     # TODO Abstract base class for different environments
     """
 
-    def __init__(self, layout_path):
+    def __init__(self, layout_path, item_info_path):
         self.players: dict[str, Player] = {}
         self.counter_side_length: int = 40
         self.layout_path: Path = layout_path
+        self.item_info_path: Path = item_info_path
+        self.item_info = self.load_item_info()
 
         self.game_score = GameScore()
         self.SYMBOL_TO_CHARACTER_MAP = {
@@ -66,11 +71,14 @@ class Environment:
             "B": CuttingBoard,
             "X": Trash,
             "W": lambda pos: ServingWindow(pos, self.game_score),
-            "T": lambda pos: Dispenser(pos, "Tomato"),
-            "L": lambda pos: Dispenser(pos, "Lettuce"),
-            "P": PlateReturn,
+            "T": lambda pos: Dispenser(pos, self.item_info["Tomato"]),
+            "L": lambda pos: Dispenser(pos, self.item_info["Lettuce"]),
+            "P": lambda pos: Dispenser(pos, self.item_info["Plate"]),
             "E": None,
-            "U": Stove,  # Stove with pot: U because it looks like a pot
+            "U": lambda pos: Stove(
+                pos,
+                self.item_info["Pot"].create_item(),
+            ),  # Stove with pot: U because it looks like a pot
         }
 
         self.counters: list[Counter] = self.create_counters(self.layout_path)
@@ -78,6 +86,18 @@ class Environment:
         self.world_width: int = 800
         self.world_height: int = 600
 
+    def load_item_info(self) -> dict[str, ItemInfo]:
+        with open(self.item_info_path, "r") as file:
+            item_lookup = yaml.safe_load(file)
+        for item_name in item_lookup:
+            item_lookup[item_name] = ItemInfo(name=item_name, **item_lookup[item_name])
+
+        for item_name, item_info in item_lookup.items():
+            if item_info.equipment:
+                item_info.equipment = item_lookup[item_info.equipment]
+                item_info.equipment.add_start_item_to_equipment(item_info)
+        return item_lookup
+
     def create_counters(self, layout_file: Path):
         """Creates layout of kitchen counters in the environment based on layout file.
         Counters are arranged in a fixed size grid starting at [0,0]. The center of the first counter is at
diff --git a/overcooked_simulator/pygame_gui/pygame_gui.py b/overcooked_simulator/pygame_gui/pygame_gui.py
index a086961c..1540d625 100644
--- a/overcooked_simulator/pygame_gui/pygame_gui.py
+++ b/overcooked_simulator/pygame_gui/pygame_gui.py
@@ -1,5 +1,4 @@
 import math
-from pathlib import Path
 
 import numpy as np
 import numpy.typing as npt
@@ -13,7 +12,6 @@ from overcooked_simulator.game_items import (
     Item,
     CookingEquipment,
     Meal,
-    Soup,
 )
 from overcooked_simulator.overcooked_environment import Action
 from overcooked_simulator.simulation_runner import Simulator
@@ -84,10 +82,10 @@ class PyGameGUI:
             for player_name, keys in zip(self.player_names, self.player_keys)
         ]
 
-        with open(ROOT_DIR / "pygame_gui/visualization.yaml", "r") as file:
+        with open(ROOT_DIR / "pygame_gui" / "visualization.yaml", "r") as file:
             self.visualization_config = yaml.safe_load(file)
 
-        self.images_path = Path(ROOT_DIR, "pygame_gui", "images")
+        self.images_path = ROOT_DIR / "pygame_gui" / "images"
 
     def send_action(self, action: Action):
         """Sends an action to the game environment.
@@ -179,7 +177,7 @@ class PyGameGUI:
             else:
                 img_path = self.visualization_config["Cook"]["parts"][0]["path"]
                 image = pygame.image.load(
-                    ROOT_DIR / Path("pygame_gui") / img_path
+                    ROOT_DIR / "pygame_gui" / img_path
                 ).convert_alpha()
                 rel_x, rel_y = player.facing_direction
                 angle = -np.rad2deg(math.atan2(rel_y, rel_x)) + 90
@@ -206,7 +204,7 @@ class PyGameGUI:
             part_type = part["type"]
             if part_type == "image":
                 image = pygame.image.load(
-                    ROOT_DIR / Path("pygame_gui") / parts[0]["path"]
+                    ROOT_DIR / "pygame_gui" / parts[0]["path"]
                 ).convert_alpha()
 
                 size = parts[0]["size"]
@@ -267,25 +265,13 @@ class PyGameGUI:
         if isinstance(item, CookingEquipment) and item.content:
             self.draw_item(pos, item.content)
 
-        if isinstance(item, Meal) and item.parts and not isinstance(item, Soup):
-            for i, o in enumerate(item.parts):
-                self.draw_item(np.abs([pos[0], pos[1] - (i * 5)]), o)
-
-        if isinstance(item, Soup) and item.parts:
-            if item.parts:
-                if not item.finished:
-                    match len(item.parts):
-                        case 1:
-                            pygame.draw.circle(self.screen, RED, pos, 4)
-                        case 2:
-                            pygame.draw.circle(self.screen, RED, pos, 8)
-                        case 3:
-                            pygame.draw.circle(self.screen, RED, pos, 12)
-                else:
+        if isinstance(item, Meal):
+            if "Soup" in item.name:
+                if item.finished:
                     if item.name in self.visualization_config:
                         image = pygame.image.load(
                             ROOT_DIR
-                            / Path("pygame_gui")
+                            / "pygame_gui"
                             / self.visualization_config[item.name]["parts"][0]["path"]
                         ).convert_alpha()
 
@@ -295,6 +281,17 @@ class PyGameGUI:
                         rect = image.get_rect()
                         rect.center = pos
                         self.screen.blit(image, rect)
+                elif item.parts:
+                    match len(item.parts):
+                        case 1:
+                            pygame.draw.circle(self.screen, RED, pos, 4)
+                        case 2:
+                            pygame.draw.circle(self.screen, RED, pos, 8)
+                        case 3:
+                            pygame.draw.circle(self.screen, RED, pos, 12)
+            else:
+                for i, o in enumerate(item.parts):
+                    self.draw_item(np.abs([pos[0], pos[1] - (i * 5)]), o)
 
     def draw_progress_bar(self, pos, current, needed):
         """Visualize progress of progressing item as a green bar under the item."""
diff --git a/overcooked_simulator/pygame_gui/visualization.yaml b/overcooked_simulator/pygame_gui/visualization.yaml
index 758f7372..67c6f455 100644
--- a/overcooked_simulator/pygame_gui/visualization.yaml
+++ b/overcooked_simulator/pygame_gui/visualization.yaml
@@ -17,7 +17,7 @@ CuttingBoard:
       center_offset: [ +6, -8 ]
       color: [ 120, 120, 120 ]
 
-PlateReturn:
+PlateDispenser:
   parts:
     - type: "rect"
       height: 38
@@ -129,7 +129,7 @@ TomatoSoup3of3:
       radius: 12
       color: [ 255, 0, 0 ]
 
-CookedSoup:
+TomatoSoup:
   parts:
     - type: "image"
       path: "images/tomato_soup.png"
diff --git a/overcooked_simulator/simulation_runner.py b/overcooked_simulator/simulation_runner.py
index 31d9d377..6c9cf367 100644
--- a/overcooked_simulator/simulation_runner.py
+++ b/overcooked_simulator/simulation_runner.py
@@ -1,6 +1,7 @@
 import time
 from threading import Thread
 
+from overcooked_simulator import ROOT_DIR
 from overcooked_simulator.overcooked_environment import Environment, Action
 from overcooked_simulator.player import Player
 
@@ -19,12 +20,17 @@ class Simulator(Thread):
     ```
     """
 
-    def __init__(self, env_layout_path, frequency: int):
+    def __init__(
+        self,
+        env_layout_path,
+        frequency: int,
+        item_info_path=ROOT_DIR / "game_content" / "item_info.yaml",
+    ):
         self.finished: bool = False
 
         self.step_frequency: int = frequency
         self.preferred_sleep_time_ns: float = 1e9 / self.step_frequency
-        self.env: Environment = Environment(env_layout_path)
+        self.env: Environment = Environment(env_layout_path, item_info_path)
 
         super().__init__()
 
diff --git a/tests/test_start.py b/tests/test_start.py
index e23635c2..729bd43e 100644
--- a/tests/test_start.py
+++ b/tests/test_start.py
@@ -1,5 +1,4 @@
 import time
-from pathlib import Path
 
 import numpy as np
 import pytest
@@ -11,7 +10,7 @@ from overcooked_simulator.overcooked_environment import Action
 from overcooked_simulator.player import Player
 from overcooked_simulator.simulation_runner import Simulator
 
-layouts_folder = Path(ROOT_DIR / "game_content/layouts")
+layouts_folder = ROOT_DIR / "game_content" / "layouts"
 
 
 @pytest.fixture(autouse=True)
-- 
GitLab