From c78999684f80ea00b4e04c963938d8e05512e8a6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Florian=20Schr=C3=B6der?=
 <fschroeder@techfak.uni-bielefeld.de>
Date: Sat, 3 Feb 2024 18:02:17 +0100
Subject: [PATCH] Implement game effects and tools framework

Added the definition and interactions for game effects and tools. The effect type was introduced to influence the player's interactive abilities or alter item's attributes. The tool type extends the player's interactive abilities. It also includes the part related to fire and fire extinguisher for more realistic simulation.
---
 overcooked_simulator/counter_factory.py       |  65 +++++++-
 overcooked_simulator/counters.py              | 152 ++++++++++--------
 overcooked_simulator/effect_manager.py        | 133 +++++++++++++++
 .../game_content/environment_config.yaml      |  10 ++
 .../game_content/item_info.yaml               |  18 ++-
 .../game_content/item_info_debug.yaml         |  15 ++
 .../game_content/layouts/basic.layout         |   2 +-
 overcooked_simulator/game_items.py            | 105 ++++++++++--
 overcooked_simulator/gui_2d_vis/drawing.py    |  18 ++-
 .../gui_2d_vis/visualization.yaml             |  20 +++
 .../overcooked_environment.py                 |  23 ++-
 overcooked_simulator/player.py                |  33 ++--
 overcooked_simulator/state_representation.py  |  16 +-
 overcooked_simulator/utils.py                 |  27 +++-
 14 files changed, 519 insertions(+), 118 deletions(-)
 create mode 100644 overcooked_simulator/effect_manager.py

diff --git a/overcooked_simulator/counter_factory.py b/overcooked_simulator/counter_factory.py
index 02abcf47..757cddfe 100644
--- a/overcooked_simulator/counter_factory.py
+++ b/overcooked_simulator/counter_factory.py
@@ -50,7 +50,14 @@ from overcooked_simulator.counters import (
     SinkAddon,
     Trashcan,
 )
-from overcooked_simulator.game_items import ItemInfo, ItemType, CookingEquipment, Plate
+from overcooked_simulator.effect_manager import EffectManager
+from overcooked_simulator.game_items import (
+    ItemInfo,
+    ItemType,
+    CookingEquipment,
+    Plate,
+    Item,
+)
 from overcooked_simulator.hooks import Hooks
 from overcooked_simulator.order import OrderAndScoreManager
 from overcooked_simulator.utils import get_closest
@@ -112,6 +119,7 @@ class CounterFactory:
         serving_window_additional_kwargs: dict[str, Any],
         plate_config: PlateConfig,
         order_and_score: OrderAndScoreManager,
+        effect_manager_config: dict,
         hook: Hooks,
     ) -> None:
         """Constructor for the `CounterFactory` class. Set up the attributes necessary to instantiate the counters.
@@ -142,6 +150,8 @@ class CounterFactory:
         """The plate config from the `environment_config`"""
         self.order_and_score: OrderAndScoreManager = order_and_score
         """The order and score manager to pass to `ServingWindow` and the `Tashcan` which can affect the scores."""
+        self.effect_manager_config = effect_manager_config
+        """The effect manager config to setup the effect manager based on the defined effects in the item info."""
 
         self.no_counter_chars: set[str] = set(
             c
@@ -190,13 +200,20 @@ class CounterFactory:
                             name=item_info.name,
                             item_info=item_info,
                             transitions=self.filter_item_info(
-                                by_equipment_name=item_info.name
+                                by_equipment_name=item_info.name,
+                                add_effects=True,
                             ),
                         ),
                         hook=self.hook,
                     )
             elif item_info.type == ItemType.Ingredient:
                 return Dispenser(pos=pos, hook=self.hook, dispensing=item_info)
+            elif item_info.type == ItemType.Tool:
+                return Counter(
+                    pos=pos,
+                    hook=self.hook,
+                    occupied_by=Item(name=item_info.name, item_info=item_info),
+                )
 
         if counter_class is None:
             counter_class = self.counter_classes[self.layout_chars_config[c]]
@@ -206,13 +223,14 @@ class CounterFactory:
         }
         if issubclass(counter_class, (CuttingBoard, Sink)):
             kwargs["transitions"] = self.filter_item_info(
-                by_equipment_name=counter_class.__name__
+                by_equipment_name=counter_class.__name__,
+                add_effects=True,
             )
         elif issubclass(counter_class, PlateDispenser):
             kwargs.update(
                 {
                     "plate_transitions": self.filter_item_info(
-                        by_item_type=ItemType.Meal
+                        by_item_type=ItemType.Meal, add_effects=True
                     ),
                     "plate_config": self.plate_config,
                     "dispensing": self.item_info[Plate.__name__],
@@ -249,21 +267,32 @@ class CounterFactory:
         self,
         by_item_type: ItemType = None,
         by_equipment_name: str = None,
+        add_effects: bool = False,
     ) -> dict[str, ItemInfo]:
         """Filter the item info dict by item type or equipment name"""
+        filtered = {}
         if by_item_type is not None:
-            return {
+            filtered = {
                 name: info
                 for name, info in self.item_info.items()
                 if info.type == by_item_type
             }
         if by_equipment_name is not None:
-            return {
+            filtered = {
                 name: info
                 for name, info in self.item_info.items()
                 if info.equipment is not None
                 and info.equipment.name == by_equipment_name
             }
+        if add_effects:
+            for name, effect in self.filter_item_info(
+                by_item_type=ItemType.Effect
+            ).items():
+                for need in effect.needs:
+                    if need in filtered:
+                        filtered.update({name: effect})
+        if by_item_type or by_equipment_name:
+            return filtered
         return self.item_info
 
     def post_counter_setup(self, counters: list[Counter]):
@@ -289,11 +318,33 @@ class CounterFactory:
                     counter: Sink  # Pycharm type checker does now work for match statements?
                     assert len(sink_addons) > 0, "No SinkAddon but normal Sink"
                     closest_addon = get_closest(pos, sink_addons)
-                    assert 1 - (1 * 0.05) <= np.linalg.norm(
+                    assert 1.0 == np.linalg.norm(
                         closest_addon.pos - pos
                     ), f"No SinkAddon connected to Sink at pos {pos}"
                     counter.set_addon(closest_addon)
 
+    def setup_effect_manger(self, counters: list[Counter]) -> dict[str, EffectManager]:
+        effect_manager = {}
+        for name, effect in self.filter_item_info(by_item_type=ItemType.Effect).items():
+            assert (
+                effect.manager in self.effect_manager_config
+            ), f"Manager for effect not found: {name} -> {effect.manager} not in {list(self.effect_manager_config.keys())}"
+            if effect.manager in effect_manager:
+                manager = effect_manager[effect.manager]
+            else:
+                manager = self.effect_manager_config[effect.manager]["class"](
+                    hook=self.hook,
+                    **self.effect_manager_config[effect.manager]["kwargs"],
+                )
+                manager.set_counters(counters)
+                effect_manager[effect.manager] = manager
+
+            manager.add_effect(effect)
+
+            effect.manager = manager
+
+        return effect_manager
+
     @staticmethod
     def get_counter_of_type(counter_type: Type[T], counters: list[Counter]) -> list[T]:
         """Filter all counters in the environment for a counter type."""
diff --git a/overcooked_simulator/counters.py b/overcooked_simulator/counters.py
index d4b72eec..697a3394 100644
--- a/overcooked_simulator/counters.py
+++ b/overcooked_simulator/counters.py
@@ -48,8 +48,6 @@ from overcooked_simulator.hooks import (
     PRE_DISPENSER_PICK_UP,
     CUTTING_BOARD_PROGRESS,
     CUTTING_BOARD_100,
-    CUTTING_BOARD_START_INTERACT,
-    CUTTING_BOARD_END_INTERACT,
     PRE_COUNTER_PICK_UP,
     POST_COUNTER_PICK_UP,
     PRE_SERVING,
@@ -58,14 +56,13 @@ from overcooked_simulator.hooks import (
     DIRTY_PLATE_ARRIVES,
     TRASHCAN_USAGE,
     PLATE_CLEANED,
-    SINK_START_INTERACT,
-    SINK_END_INTERACT,
     ADDED_PLATE_TO_SINK,
     DROP_ON_SINK_ADDON,
     PICK_UP_FROM_SINK_ADDON,
 )
 
 if TYPE_CHECKING:
+    from overcooked_simulator.effect_manager import Effect
     from overcooked_simulator.overcooked_environment import (
         OrderAndScoreManager,
     )
@@ -78,6 +75,7 @@ from overcooked_simulator.game_items import (
     CookingEquipment,
     Plate,
     ItemInfo,
+    EffectType,
 )
 
 
@@ -114,6 +112,8 @@ class Counter:
         """The position of the counter."""
         self.occupied_by: Optional[Item] = occupied_by
         """What is on top of the counter, e.g., `Item`s."""
+        self.active_effects: list[Effect] = []
+        """The effects that currently affect the usage of the counter."""
         self.hook = hook
         """Reference to the hook manager."""
 
@@ -190,19 +190,45 @@ class Counter:
             return self.occupied_by.combine(item)
         return None
 
-    def interact_start(self):
-        """Starts an interaction by the player. Nothing happens for the standard counter."""
-        pass
-
-    def interact_stop(self):
-        """Stops an interaction by the player. Nothing happens for the standard counter."""
-        pass
-
     def __repr__(self):
         return (
             f"{self.__class__.__name__}(pos={self.pos},occupied_by={self.occupied_by})"
         )
 
+    def do_tool_interaction(self, passed_time: timedelta, tool: Item):
+        successful = False
+        if self.occupied_by:
+            if isinstance(self.occupied_by, deque):
+                for item in self.occupied_by:
+                    successful |= self._do_single_tool_interaction(
+                        passed_time, tool, item
+                    )
+            else:
+                successful = self._do_single_tool_interaction(
+                    passed_time, tool, self.occupied_by
+                )
+        if not successful:
+            self._do_single_tool_interaction(passed_time, tool, self)
+
+    def _do_single_tool_interaction(
+        self, passed_time: timedelta, tool: Item, target: Item | Counter
+    ) -> bool:
+        suitable_effects = [
+            e for e in target.active_effects if e.name in tool.item_info.needs
+        ]
+        if suitable_effects:
+            effect = suitable_effects[0]
+            percent = passed_time.total_seconds() / tool.item_info.seconds
+            effect.progres_percentage += percent
+            if effect.progres_percentage > 1.0:
+                effect.item_info.manager.remove_active_effect(effect, target)
+                target.active_effects.remove(effect)
+            return True
+        return False
+
+    def do_hand_free_interaction(self, passed_time: timedelta, now: datetime):
+        ...
+
     def to_dict(self) -> dict:
         """For the state representation. Only the relevant attributes are put into the dict."""
         return {
@@ -217,6 +243,7 @@ class Counter:
                 if isinstance(self.occupied_by, Iterable)
                 else self.occupied_by.to_dict()
             ),
+            "active_effects": [e.to_dict() for e in self.active_effects],
         }
 
 
@@ -233,8 +260,6 @@ class CuttingBoard(Counter):
     """
 
     def __init__(self, transitions: dict[str, ItemInfo], **kwargs):
-        self.progressing: bool = False
-        """Is a player progressing/cutting on the board."""
         self.transitions: dict[str, ItemInfo] = transitions
         """The allowed transitions to a new item. Keys are the resulting items and the `ItemInfo` (value) contains 
         the needed items in the `need` attribute."""
@@ -245,7 +270,7 @@ class CuttingBoard(Counter):
         board."""
         super().__init__(**kwargs)
 
-    def progress(self, passed_time: timedelta, now: datetime):
+    def do_hand_free_interaction(self, passed_time: timedelta, now: datetime):
         """Called by environment step function for time progression.
 
         Args:
@@ -258,8 +283,15 @@ class CuttingBoard(Counter):
         """
         if (
             self.occupied
-            and self.progressing
             and self.occupied_by.name in self.inverted_transition_dict
+            and not any(
+                e.item_info.effect_type == EffectType.Unusable
+                for e in self.occupied_by.active_effects
+            )
+            and not any(
+                e.item_info.effect_type == EffectType.Unusable
+                for e in self.active_effects
+            )
         ):
             percent = (
                 passed_time.total_seconds()
@@ -281,29 +313,6 @@ class CuttingBoard(Counter):
                 ].name
                 self.hook(CUTTING_BOARD_100, counter=self)
 
-    def start_progress(self):
-        """Starts the cutting process."""
-        self.progressing = True
-
-    def pause_progress(self):
-        """Pauses the cutting process"""
-        self.progressing = False
-
-    def interact_start(self):
-        """Handles player interaction, starting to hold key down."""
-        self.start_progress()
-        self.hook(CUTTING_BOARD_START_INTERACT, counter=self)
-
-    def interact_stop(self):
-        """Handles player interaction, stopping to hold key down."""
-        self.pause_progress()
-        self.hook(CUTTING_BOARD_END_INTERACT, counter=self)
-
-    def to_dict(self) -> dict:
-        d = super().to_dict()
-        d.update((("progressing", self.progressing),))
-        return d
-
 
 class ServingWindow(Counter):
     """The orders and scores are updated based on completed and dropped off meals. The plate dispenser is pinged for
@@ -349,6 +358,14 @@ class ServingWindow(Counter):
         return item
 
     def can_drop_off(self, item: Item) -> bool:
+        if any(
+            e.item_info.effect_type == EffectType.Unusable for e in self.active_effects
+        ):
+            return False
+        if any(
+            e.item_info.effect_type == EffectType.Unusable for e in item.active_effects
+        ):
+            return False
         return isinstance(item, CookingEquipment) and (
             (item.content_ready is not None and item.content_ready.name in self.meals)
             or (len(item.content_list) == 1 and item.content_list[0].name in self.meals)
@@ -573,6 +590,12 @@ class Trashcan(Counter):
         pass
 
     def drop_off(self, item: Item) -> Item | None:
+        if any(
+            e.item_info.effect_type == EffectType.Unusable for e in item.active_effects
+        ) or any(
+            e.item_info.effect_type == EffectType.Unusable for e in self.active_effects
+        ):
+            return item
         if isinstance(item, CookingEquipment):
             penalty = self.order_and_score.apply_penalty_for_using_trash(
                 item.content_list
@@ -625,6 +648,10 @@ class CookingCounter(Counter):
             and isinstance(self.occupied_by, CookingEquipment)
             and self.occupied_by.name in self.equipments
             and self.occupied_by.can_progress()
+            and not any(
+                e.item_info.effect_type == EffectType.Unusable
+                for e in self.active_effects
+            )
         ):
             self.occupied_by.progress(passed_time, now)
 
@@ -657,8 +684,6 @@ class Sink(Counter):
         **kwargs,
     ):
         super().__init__(**kwargs)
-        self.progressing: bool = False
-        """If a player currently cleans a plate."""
         self.sink_addon: SinkAddon = sink_addon
         """The connected sink addon which will receive the clean plates"""
         self.occupied_by: deque[Plate] = deque()
@@ -679,12 +704,30 @@ class Sink(Counter):
         """If there is a plate in the sink."""
         return len(self.occupied_by) != 0
 
-    def progress(self, passed_time: timedelta, now: datetime):
+    def do_hand_free_interaction(self, passed_time: timedelta, now: datetime):
         """Called by environment step function for time progression"""
         if (
             self.occupied
-            and self.progressing
             and self.occupied_by[-1].name in self.transition_needs
+            and not any(
+                e.item_info.effect_type == EffectType.Unusable
+                for e in self.active_effects
+            )
+            and not any(
+                e.item_info.effect_type == EffectType.Unusable
+                for e in self.sink_addon.active_effects
+            )
+            and not any(
+                e.item_info.effect_type == EffectType.Unusable
+                for e in self.occupied_by[-1].active_effects
+            )
+            and (
+                not self.sink_addon.occupied_by
+                or not any(
+                    e.item_info.effect_type == EffectType.Unusable
+                    for e in self.sink_addon.occupied_by[-1].active_effects
+                )
+            )
         ):
             for name, info in self.transitions.items():
                 if info.needs[0] == self.occupied_by[-1].name:
@@ -701,24 +744,6 @@ class Sink(Counter):
                         self.sink_addon.add_clean_plate(plate)
                     break
 
-    def start_progress(self):
-        """Starts the cutting process."""
-        self.progressing = True
-
-    def pause_progress(self):
-        """Pauses the cutting process"""
-        self.progressing = False
-
-    def interact_start(self):
-        """Handles player interaction, starting to hold key down."""
-        self.start_progress()
-        self.hook(SINK_START_INTERACT, counter=self)
-
-    def interact_stop(self):
-        """Handles player interaction, stopping to hold key down."""
-        self.pause_progress()
-        self.hook(SINK_END_INTERACT, counter=self)
-
     def can_drop_off(self, item: Item) -> bool:
         return isinstance(item, Plate) and not item.clean
 
@@ -734,11 +759,6 @@ class Sink(Counter):
         """Set the closest addon in post_setup."""
         self.sink_addon = sink_addon
 
-    def to_dict(self) -> dict:
-        d = super().to_dict()
-        d.update((("progressing", self.progressing),))
-        return d
-
 
 class SinkAddon(Counter):
     """The counter on which the clean plates appear after cleaning them in the `Sink`
diff --git a/overcooked_simulator/effect_manager.py b/overcooked_simulator/effect_manager.py
new file mode 100644
index 00000000..905fef95
--- /dev/null
+++ b/overcooked_simulator/effect_manager.py
@@ -0,0 +1,133 @@
+from __future__ import annotations
+
+import random
+from collections import deque
+from datetime import timedelta, datetime
+from typing import TYPE_CHECKING, Tuple
+
+from overcooked_simulator.game_items import (
+    ItemInfo,
+    Item,
+    ItemType,
+    Effect,
+    CookingEquipment,
+)
+from overcooked_simulator.hooks import Hooks
+from overcooked_simulator.utils import get_touching_counters, find_item_on_counters
+
+if TYPE_CHECKING:
+    from overcooked_simulator.counters import Counter
+
+
+class EffectManager:
+    def __init__(self, hook: Hooks):
+        self.effects = []
+        self.counters = []
+        self.hook = hook
+        self.new_effects: list[Tuple[Effect, Item | Counter]] = []
+
+    def add_effect(self, effect: ItemInfo):
+        self.effects.append(effect)
+
+    def set_counters(self, counters: list[Counter]):
+        self.counters.extend(counters)
+
+    def register_active_effect(self, effect: Effect, target: Item | Counter):
+        target.active_effects.append(effect)
+        self.new_effects.append((effect, target))
+
+    def progress(self, passed_time: timedelta, now: datetime):
+        ...
+
+    def can_start_effect_transition(
+        self, effect: ItemInfo, target: Item | Counter
+    ) -> bool:
+        return effect.name not in [e.name for e in target.active_effects]
+
+    def remove_active_effect(self, effect: Effect, target: Item | Counter):
+        ...
+
+
+class FireEffectManager(EffectManager):
+    # TODO add Random object
+
+    def __init__(
+        self,
+        spreading_duration: list[float],
+        fire_burns_ingredients_and_meals: bool,
+        **kwargs,
+    ):
+        super().__init__(**kwargs)
+        self.spreading_duration = spreading_duration
+        self.fire_burns_ingredients_and_meals = fire_burns_ingredients_and_meals
+        self.effect_to_timer: dict[str:datetime] = {}
+        self.next_finished_timer = datetime.max
+        self.active_effects: list[Tuple[Effect, Item | Counter]] = []
+
+    def progress(self, passed_time: timedelta, now: datetime):
+        if self.new_effects:
+            for effect, target in self.new_effects:
+                self.effect_to_timer[effect.uuid] = now + timedelta(
+                    seconds=random.uniform(*self.spreading_duration)
+                )
+                self.next_finished_timer = min(
+                    self.next_finished_timer, self.effect_to_timer[effect.uuid]
+                )
+                self.active_effects.append((effect, target))
+            self.new_effects = []
+        if self.next_finished_timer < now:
+            for effect, target in self.active_effects:
+                if self.effect_to_timer[effect.uuid] < now:
+                    if isinstance(target, Item):
+                        target = find_item_on_counters(target.uuid, self.counters)
+                    if target:
+                        touching = get_touching_counters(target, self.counters)
+                        for counter in touching:
+                            if counter.occupied_by:
+                                if isinstance(counter.occupied_by, deque):
+                                    self.apply_effect(effect, counter.occupied_by[-1])
+                                else:
+                                    self.apply_effect(effect, counter.occupied_by)
+                            else:
+                                self.apply_effect(effect, counter)
+                    self.effect_to_timer[effect.uuid] = now + timedelta(
+                        seconds=random.uniform(*self.spreading_duration)
+                    )
+            if self.effect_to_timer:
+                self.next_finished_timer = min(self.effect_to_timer.values())
+            else:
+                self.next_finished_timer = datetime.max
+
+    def apply_effect(self, effect: Effect, target: Item | Counter):
+        if (
+            isinstance(target, Item)
+            and target.item_info.type == ItemType.Tool
+            and effect.name in target.item_info.needs
+        ):
+            # Tools that reduce fire can not burn
+            return
+        if effect.name not in target.active_effects and target.uuid not in [
+            t.uuid for _, t in self.active_effects
+        ]:
+            if isinstance(target, CookingEquipment):
+                if target.content_list:
+                    for content in target.content_list:
+                        self.burn_content(content)
+                    if self.fire_burns_ingredients_and_meals:
+                        self.burn_content(target.content_ready)
+            elif isinstance(target, Item):
+                self.burn_content(target)
+            self.register_active_effect(
+                Effect(effect.name, item_info=effect.item_info), target
+            )
+
+    def burn_content(self, content: Item):
+        if self.fire_burns_ingredients_and_meals and content:
+            if not content.name.startswith("Burnt"):
+                content.name = "Burnt" + content.name
+
+    def remove_active_effect(self, effect: Effect, target: Item | Counter):
+        if (effect, target) in self.active_effects:
+            self.active_effects.remove((effect, target))
+        if effect.uuid in self.effect_to_timer:
+            del self.effect_to_timer[effect.uuid]
diff --git a/overcooked_simulator/game_content/environment_config.yaml b/overcooked_simulator/game_content/environment_config.yaml
index c0de86d2..3cf44456 100644
--- a/overcooked_simulator/game_content/environment_config.yaml
+++ b/overcooked_simulator/game_content/environment_config.yaml
@@ -20,6 +20,7 @@ layout_chars:
   _: Free
   hash: Counter
   A: Agent
+  pipe: Extinguisher
   P: PlateDispenser
   C: CuttingBoard
   X: Trashcan
@@ -41,6 +42,7 @@ layout_chars:
   B: Bun
   M: Meat
 
+
 orders:
   order_gen_class: !!python/name:overcooked_simulator.order.RandomOrderGeneration ''
   # the class to that receives the kwargs. Should be a child class of OrderGeneration in order.py
@@ -85,3 +87,11 @@ player_config:
   radius: 0.4
   player_speed_units_per_seconds: 8
   interaction_range: 1.6
+
+
+effect_manager:
+  FireManager:
+    class: !!python/name:overcooked_simulator.effect_manager.FireEffectManager ''
+    kwargs:
+      spreading_duration: [ 5, 10 ]
+      fire_burns_ingredients_and_meals: true
\ No newline at end of file
diff --git a/overcooked_simulator/game_content/item_info.yaml b/overcooked_simulator/game_content/item_info.yaml
index 639f8551..1266f61e 100644
--- a/overcooked_simulator/game_content/item_info.yaml
+++ b/overcooked_simulator/game_content/item_info.yaml
@@ -213,4 +213,20 @@ BurntPizza:
   type: Waste
   needs: [ Pizza ]
   seconds: 7.0
-  equipment: Peel
\ No newline at end of file
+  equipment: Peel
+
+# --------------------------------------------------------------------------------
+
+Fire:
+  type: Effect
+  seconds: 5.0
+  needs: [ BurntCookedPatty, BurntChips, BurntFriedFish, BurntTomatoSoup, BurntOnionSoup, BurntPizza ]
+  manager: FireManager
+  effect_type: Unusable
+
+# --------------------------------------------------------------------------------
+
+Extinguisher:
+  type: Tool
+  seconds: 1.0
+  needs: [ Fire ]
diff --git a/overcooked_simulator/game_content/item_info_debug.yaml b/overcooked_simulator/game_content/item_info_debug.yaml
index 6340f564..15d3b606 100644
--- a/overcooked_simulator/game_content/item_info_debug.yaml
+++ b/overcooked_simulator/game_content/item_info_debug.yaml
@@ -216,3 +216,18 @@ BurntPizza:
   seconds: 7.0
   equipment: Peel
 
+# --------------------------------------------------------------------------------
+
+Fire:
+  type: Effect
+  seconds: 10.0
+  needs: [ BurntCookedPatty, BurntChips, BurntFriedFish, BurntTomatoSoup, BurntOnionSoup, BurntPizza ]
+  manager: FireManager
+  effect_type: Unusable
+
+# --------------------------------------------------------------------------------
+
+Extinguisher:
+  type: Tool
+  seconds: 0.1
+  needs: [ Fire ]
\ No newline at end of file
diff --git a/overcooked_simulator/game_content/layouts/basic.layout b/overcooked_simulator/game_content/layouts/basic.layout
index ccc40763..94e5fb1d 100644
--- a/overcooked_simulator/game_content/layouts/basic.layout
+++ b/overcooked_simulator/game_content/layouts/basic.layout
@@ -1,6 +1,6 @@
 #QU#FO#TNLB#
 #__________M
-#__________K
+|__________K
 W__________I
 #__A_____A_D
 C__________E
diff --git a/overcooked_simulator/game_items.py b/overcooked_simulator/game_items.py
index 163a2d13..0b7c6d6d 100644
--- a/overcooked_simulator/game_items.py
+++ b/overcooked_simulator/game_items.py
@@ -26,7 +26,10 @@ import datetime
 import logging
 import uuid
 from enum import Enum
-from typing import Optional, TypedDict
+from typing import Optional, TypedDict, TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from overcooked_simulator.effect_manager import EffectManager
 
 log = logging.getLogger(__name__)
 """The logger for this module."""
@@ -38,6 +41,10 @@ COOKING_EQUIPMENT_ITEM_CATEGORY = "ItemCookingEquipment"
 """The string for the `category` value in the json state representation for all cooking equipments."""
 
 
+class EffectType(Enum):
+    Unusable = "Unusable"
+
+
 class ItemType(Enum):
     Ingredient = "Ingredient"
     """All ingredients and process ingredients."""
@@ -47,6 +54,10 @@ class ItemType(Enum):
     """All counters and cooking equipments."""
     Waste = "Waste"
     """Burnt ingredients and meals."""
+    Effect = "Effect"
+    """Does not change the item but the object attributes, like adding fire."""
+    Tool = "Tool"
+    """Item that remains in hands in extends the interactive abilities of the player."""
 
 
 @dataclasses.dataclass
@@ -89,16 +100,24 @@ class ItemInfo:
     """The name of the item, is set automatically by the "group" name of the item."""
     seconds: float = dataclasses.field(compare=False, default=0)
     """If progress is needed this argument defines how long it takes to complete the process in seconds."""
+
+    # TODO maybe as a lambda/based on Prefix?
     needs: list[str] = dataclasses.field(compare=False, default_factory=list)
     """The ingredients/items which are needed to create the item/start the progress."""
     equipment: ItemInfo | None = dataclasses.field(compare=False, default=None)
     """On which the item can be created. `null`, `~` (None) converts to Plate."""
+    manager: str | None | EffectManager = None
+    """The manager for the effect."""
+    effect_type: None | EffectType = None
+    """How does the effect effect interaction, combine actions etc."""
 
     recipe: collections.Counter | None = None
     """Internally set in CookingEquipment"""
 
     def __post_init__(self):
         self.type = ItemType(self.type)
+        if self.effect_type:
+            self.effect_type = EffectType(self.effect_type)
 
 
 class ActiveTransitionTypedDict(TypedDict):
@@ -106,7 +125,7 @@ class ActiveTransitionTypedDict(TypedDict):
 
     seconds: int | float
     """The needed seconds to progress for the transition."""
-    result: str
+    result: str | Item | Effect
     """The new name of the item after the transition."""
 
 
@@ -127,10 +146,12 @@ class Item:
         """The equipment with which the item was last progressed."""
         self.progress_percentage: float = 0.0
         """The current progress percentage of the item if it is progress-able."""
-        self.waste_progress: bool = False
+        self.inverse_progress: bool = False
         """Whether the progress will produce waste."""
         self.uuid: str = uuid.uuid4().hex if uid is None else uid
         """A unique identifier for the item. Useful for GUIs that handles specific asset instances."""
+        self.active_effects: list[Effect] = []
+        """The effects that affect the item."""
 
     def __repr__(self):
         if self.progress_equipment is None:
@@ -171,7 +192,7 @@ class Item:
         """Reset the progress."""
         self.progress_equipment = None
         self.progress_percentage = 0.0
-        self.waste_progress = False
+        self.inverse_progress = False
 
     def to_dict(self) -> dict:
         """For the state representation. Only the relevant attributes are put into the dict."""
@@ -180,7 +201,8 @@ class Item:
             "category": self.item_category,
             "type": self.name,
             "progress_percentage": self.progress_percentage,
-            "waste_progress": self.waste_progress,
+            "inverse_progress": self.inverse_progress,
+            "active_effects": [e.to_dict() for e in self.active_effects],
         }
 
 
@@ -215,6 +237,12 @@ class CookingEquipment(Item):
         if other is None:
             return False
 
+        if any(
+            e.item_info.effect_type == EffectType.Unusable for e in other.active_effects
+        ) or any(
+            e.item_info.effect_type == EffectType.Unusable for e in self.active_effects
+        ):
+            return False
         if isinstance(other, CookingEquipment):
             other = other.content_list
         else:
@@ -242,13 +270,22 @@ class CookingEquipment(Item):
 
     def can_progress(self) -> bool:
         """Check if the cooking equipment can progress items at all."""
-        return self.active_transition is not None
+        return self.active_transition is not None and not any(
+            e.item_info.effect_type == EffectType.Unusable for e in self.active_effects
+        )
 
     def progress(self, passed_time: datetime.timedelta, now: datetime.datetime):
         percent = passed_time.total_seconds() / self.active_transition["seconds"]
         super().progress(equipment=self.name, percent=percent)
         if self.progress_percentage == 1.0:
-            self.content_list = [self.active_transition["result"]]
+            if isinstance(self.active_transition["result"], Effect):
+                self.active_transition[
+                    "result"
+                ].item_info.manager.register_active_effect(
+                    self.active_transition["result"], self
+                )
+            else:
+                self.content_list = [self.active_transition["result"]]
             self.reset()
             self.check_active_transition()
 
@@ -257,16 +294,34 @@ class CookingEquipment(Item):
     def check_active_transition(self):
         ingredients = collections.Counter(item.name for item in self.content_list)
         for result, transition in self.transitions.items():
-            if ingredients == transition.recipe:
-                if transition.seconds == 0:
-                    self.content_ready = Item(name=result, item_info=transition)
-                else:
-                    self.active_transition = {
-                        "seconds": transition.seconds,
-                        "result": Item(name=result, item_info=transition),
-                    }
-                    self.waste_progress = transition.type == ItemType.Waste
-                break
+            if transition.type == ItemType.Effect:
+                if set(ingredients.keys()).issubset(
+                    transition.needs
+                ) and transition.manager.can_start_effect_transition(transition, self):
+                    if transition.seconds == 0:
+                        transition.manager.register_active_effect(
+                            Effect(name=transition.name, item_info=transition), self
+                        )
+                    else:
+                        self.active_transition = {
+                            "seconds": transition.seconds,
+                            "result": Effect(
+                                name=transition.name, item_info=transition
+                            ),
+                        }
+                        self.inverse_progress = True
+                    break  # ?
+            else:
+                if ingredients == transition.recipe:
+                    if transition.seconds == 0:
+                        self.content_ready = Item(name=result, item_info=transition)
+                    else:
+                        self.active_transition = {
+                            "seconds": transition.seconds,
+                            "result": Item(name=result, item_info=transition),
+                        }
+                        self.inverse_progress = transition.type == ItemType.Waste
+                    break
         else:
             self.content_ready = None
 
@@ -351,3 +406,19 @@ class Plate(CookingEquipment):
         elif self.clean:
             return True
         return False
+
+
+class Effect:
+    def __init__(self, name: str, item_info: ItemInfo, uid: str = None):
+        self.uuid: str = uuid.uuid4().hex if uid is None else uid
+        self.name = name
+        self.item_info = item_info
+        self.progres_percentage = 0.0
+
+    def to_dict(self) -> dict:
+        return {
+            "id": self.uuid,
+            "type": self.name,
+            "progress_percentage": self.progres_percentage,
+            "inverse_progress": True,
+        }
diff --git a/overcooked_simulator/gui_2d_vis/drawing.py b/overcooked_simulator/gui_2d_vis/drawing.py
index 00d4e32f..7515b212 100644
--- a/overcooked_simulator/gui_2d_vis/drawing.py
+++ b/overcooked_simulator/gui_2d_vis/drawing.py
@@ -17,6 +17,7 @@ from overcooked_simulator.state_representation import (
     PlayerState,
     CookingEquipmentState,
     ItemState,
+    EffectState,
 )
 
 USE_PLAYER_COOK_SPRITES = True
@@ -303,7 +304,7 @@ class Visualizer:
         self,
         pos: npt.NDArray[float] | list[float],
         grid_size: float,
-        item: ItemState | CookingEquipmentState,
+        item: ItemState | CookingEquipmentState | EffectState,
         scale: float = 1.0,
         plate=False,
         screen=None,
@@ -341,7 +342,7 @@ class Visualizer:
                 )
                 #
         if "progress_percentage" in item and item["progress_percentage"] > 0.0:
-            if item["waste_progress"]:
+            if item["inverse_progress"]:
                 percentage = 1 - item["progress_percentage"]
             else:
                 percentage = item["progress_percentage"]
@@ -350,7 +351,7 @@ class Visualizer:
                 pos,
                 percentage,
                 grid_size=grid_size,
-                attention=item["waste_progress"],
+                attention=item["inverse_progress"],
             )
 
         if (
@@ -386,6 +387,9 @@ class Visualizer:
                     screen=screen,
                     grid_size=grid_size,
                 )
+        if "active_effects" in item and item["active_effects"]:
+            for effect in item["active_effects"]:
+                self.draw_item(pos=pos, item=effect, screen=screen, grid_size=grid_size)
 
     @staticmethod
     def draw_progress_bar(
@@ -479,6 +483,14 @@ class Visualizer:
                     grid_size,
                     np.array(counter["pos"]) * grid_size + (grid_size / 2),
                 )
+            if counter["active_effects"]:
+                for effect in counter["active_effects"]:
+                    self.draw_item(
+                        pos=np.array(counter["pos"]) * grid_size + (grid_size / 2),
+                        grid_size=grid_size,
+                        screen=screen,
+                        item=effect,
+                    )
             if SHOW_COUNTER_CENTERS:
                 pygame.draw.circle(
                     screen,
diff --git a/overcooked_simulator/gui_2d_vis/visualization.yaml b/overcooked_simulator/gui_2d_vis/visualization.yaml
index 87415a0e..00310805 100644
--- a/overcooked_simulator/gui_2d_vis/visualization.yaml
+++ b/overcooked_simulator/gui_2d_vis/visualization.yaml
@@ -125,6 +125,26 @@ SinkAddon:
       size: 0.85
       center_offset: [ 0, 0.03 ]
 
+# Tools
+Extinguisher:
+  parts:
+    - type: rect
+      color: red
+      height: 0.2
+      width: 0.6
+    - type: rect
+      color: black
+      width: 0.1
+      height: 0.2
+      center_offset: [ 0.1, -0.3 ]
+
+# Effects
+Fire:
+  parts:
+    - type: circle
+      color: red
+      radius: 0.3
+
 # Items
 Tomato:
   parts:
diff --git a/overcooked_simulator/overcooked_environment.py b/overcooked_simulator/overcooked_environment.py
index 53d8559f..afbe8160 100644
--- a/overcooked_simulator/overcooked_environment.py
+++ b/overcooked_simulator/overcooked_environment.py
@@ -9,7 +9,7 @@ import sys
 from datetime import timedelta, datetime
 from enum import Enum
 from pathlib import Path
-from typing import Literal, TypedDict, Callable
+from typing import Literal, TypedDict, Callable, Tuple
 
 import numpy as np
 import numpy.typing as npt
@@ -20,6 +20,7 @@ from overcooked_simulator.counters import (
     Counter,
     PlateConfig,
 )
+from overcooked_simulator.effect_manager import EffectManager
 from overcooked_simulator.game_items import (
     ItemInfo,
     ItemType,
@@ -107,6 +108,7 @@ class EnvironmentConfig(TypedDict):
     orders: OrderConfig
     player_config: PlayerConfig
     layout_chars: dict[str, str]
+    effect_manager: dict
 
 
 class Environment:
@@ -197,6 +199,7 @@ class Environment:
                 )
             ),
             order_and_score=self.order_and_score,
+            effect_manager_config=self.environment_config["effect_manager"],
             hook=self.hook,
         )
 
@@ -240,6 +243,10 @@ class Environment:
         """The relative env time when it will stop/end"""
         log.debug(f"End time: {self.env_time_end}")
 
+        self.effect_manager: dict[
+            str, EffectManager
+        ] = self.counter_factory.setup_effect_manger(self.counters)
+
         self.hook(ENV_INITIALIZED)
 
     @property
@@ -325,7 +332,9 @@ class Environment:
         # TODO add colors for ingredients, equipment and meals
         # plt.show()
 
-    def parse_layout_file(self):
+    def parse_layout_file(
+        self,
+    ) -> Tuple[list[Counter], list[npt.NDArray], list[npt.NDArray]]:
         """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
         [counter_size/2, counter_size/2], counters are directly next to each other (of no empty space is specified
@@ -398,16 +407,14 @@ class Environment:
                     self.hook(ACTION_PUT, action=action, counter=counter)
                 elif action.action_type == ActionType.INTERACT:
                     if action.action_data == InterActionData.START:
-                        player.perform_interact_hold_start(counter)
-                        player.last_interacted_counter = counter
+                        player.perform_interact_start(counter)
                         self.hook(ACTION_INTERACT_START, action=action, counter=counter)
             else:
                 self.hook(
                     ACTION_ON_NOT_REACHABLE_COUNTER, action=action, counter=counter
                 )
             if action.action_data == InterActionData.STOP:
-                if player.last_interacted_counter:
-                    player.perform_interact_hold_stop(player.last_interacted_counter)
+                player.perform_interact_stop()
 
         self.hook(POST_PERFORM_ACTION, action=action)
 
@@ -642,13 +649,15 @@ class Environment:
             self.hook(GAME_ENDED_STEP)
         else:
             for player in self.players.values():
+                player.progress(passed_time, self.env_time)
                 if self.env_time <= player.movement_until:
                     self.perform_movement(player, passed_time)
 
             for counter in self.progressing_counters:
                 counter.progress(passed_time=passed_time, now=self.env_time)
             self.order_and_score.progress(passed_time=passed_time, now=self.env_time)
-
+            for effect_manager in self.effect_manager.values():
+                effect_manager.progress(passed_time=passed_time, now=self.env_time)
         # self.hook(POST_STEP, passed_time=passed_time)
 
     def get_state(self):
diff --git a/overcooked_simulator/player.py b/overcooked_simulator/player.py
index 0507e279..e2284ae0 100644
--- a/overcooked_simulator/player.py
+++ b/overcooked_simulator/player.py
@@ -7,16 +7,16 @@ holding object**. If so, it picks up the content and combines it on its hands.
 """
 
 import dataclasses
-import datetime
 import logging
 from collections import deque
+from datetime import datetime, timedelta
 from typing import Optional
 
 import numpy as np
 import numpy.typing as npt
 
 from overcooked_simulator.counters import Counter
-from overcooked_simulator.game_items import Item, Plate
+from overcooked_simulator.game_items import Item, Plate, ItemType
 from overcooked_simulator.state_representation import PlayerState
 
 log = logging.getLogger(__name__)
@@ -81,9 +81,11 @@ class Player:
 
         self.current_movement: npt.NDArray[float] = np.zeros(2, float)
         """The movement vector that will be used to calculate the movement in the next step call."""
-        self.movement_until: datetime.datetime = datetime.datetime.min
+        self.movement_until: datetime = datetime.min
         """The env time until the player wants to move."""
 
+        self.interacting: bool = False
+
     def set_movement(self, move_vector, move_until):
         """Called by the `perform_action` method. Movements will be performed (pos will be updated) in the `step`
         function of the environment"""
@@ -97,6 +99,8 @@ class Player:
         Args:
             movement: 2D-Vector of length 1
         """
+        if self.interacting and np.any(movement):
+            self.perform_interact_stop()
         self.pos += movement
         if np.linalg.norm(movement) != 0:
             self.turn(movement)
@@ -164,25 +168,36 @@ class Player:
         if isinstance(self.holding, Plate):
             log.debug(self.holding.clean)
 
-    @staticmethod
-    def perform_interact_hold_start(counter: Counter):
+    def perform_interact_start(self, counter: Counter):
         """Starts an interaction with the counter. Should be called for a
         keydown event, for holding down a key on the keyboard.
 
         Args:
             counter: The counter to start the interaction with.
         """
-        counter.interact_start()
+        self.interacting = True
+        self.last_interacted_counter = counter
 
-    @staticmethod
-    def perform_interact_hold_stop(counter: Counter):
+    def perform_interact_stop(self):
         """Stops an interaction with the counter. Should be called for a
         keyup event, for letting go of a keyboard key.
 
         Args:
             counter: The counter to stop the interaction with.
         """
-        counter.interact_stop()
+        self.interacting = False
+        self.last_interacted_counter = None
+
+    def progress(self, passed_time: timedelta, now: datetime):
+        if self.interacting and self.last_interacted_counter:
+            # TODO only interact on counter (Sink/CuttingBoard) if hands are free configure in config?
+            if self.holding:
+                if self.holding.item_info.type == ItemType.Tool:
+                    self.last_interacted_counter.do_tool_interaction(
+                        passed_time, self.holding
+                    )
+            else:
+                self.last_interacted_counter.do_hand_free_interaction(passed_time, now)
 
     def __repr__(self):
         return f"Player(name:{self.name},pos:{str(self.pos)},holds:{self.holding})"
diff --git a/overcooked_simulator/state_representation.py b/overcooked_simulator/state_representation.py
index 1949e624..6f61be46 100644
--- a/overcooked_simulator/state_representation.py
+++ b/overcooked_simulator/state_representation.py
@@ -15,12 +15,20 @@ class OrderState(TypedDict):
     max_duration: float
 
 
+class EffectState(TypedDict):
+    id: str
+    type: str
+    progress_percentage: float | int
+    inverse_progress: bool
+
+
 class ItemState(TypedDict):
     id: str
     category: Literal["Item"] | Literal["ItemCookingEquipment"]
     type: str
     progress_percentage: float | int
-    waste_progress: bool
+    inverse_progress: bool
+    active_effects: list[EffectState]
     # add ItemType Meal ?
 
 
@@ -37,14 +45,10 @@ class CounterState(TypedDict):
     occupied_by: None | list[
         ItemState | CookingEquipmentState
     ] | ItemState | CookingEquipmentState
+    active_effects: list[EffectState]
     # list[ItemState] -> type in ["Sink", "PlateDispenser"]
 
 
-class CuttingBoardAndSinkState(TypedDict):
-    type: Literal["CuttingBoard"] | Literal["Sink"]
-    progressing: bool
-
-
 class PlayerState(TypedDict):
     id: str
     pos: list[float]
diff --git a/overcooked_simulator/utils.py b/overcooked_simulator/utils.py
index 2754f089..8f22d82d 100644
--- a/overcooked_simulator/utils.py
+++ b/overcooked_simulator/utils.py
@@ -1,20 +1,25 @@
 """
 Some utility functions.
 """
+from __future__ import annotations
 
 import logging
 import os
 import sys
 import uuid
+from collections import deque
 from datetime import datetime
 from enum import Enum
+from typing import TYPE_CHECKING
 
 import numpy as np
 import numpy.typing as npt
 from scipy.spatial import distance_matrix
 
 from overcooked_simulator import ROOT_DIR
-from overcooked_simulator.counters import Counter
+
+if TYPE_CHECKING:
+    from overcooked_simulator.counters import Counter
 
 
 def create_init_env_time():
@@ -39,6 +44,26 @@ def get_closest(point: npt.NDArray[float], counters: list[Counter]):
     ]
 
 
+def get_touching_counters(target: Counter, counters: list[Counter]) -> list[Counter]:
+    return list(
+        filter(
+            lambda counter: np.linalg.norm(counter.pos - target.pos) == 1.0, counters
+        )
+    )
+
+
+def find_item_on_counters(item_uuid: str, counters: list[Counter]) -> Counter | None:
+    for counter in counters:
+        if counter.occupied_by:
+            if isinstance(counter.occupied_by, deque):
+                for item in counter.occupied_by:
+                    if item.uuid == item_uuid:
+                        return counter
+            else:
+                if item_uuid == counter.occupied_by.uuid:
+                    return counter
+
+
 def custom_asdict_factory(data):
     """Convert enums to their value."""
 
-- 
GitLab