-
Florian Schröder authored
The term "CooperativeCuisine" in multiple files has been replaced with the correct name "Cooperative Cuisine". These changes are reflected in the main code documentation, pygame_2D visualization, item type enumeration, and the CHANGELOG.
Florian Schröder authoredThe term "CooperativeCuisine" in multiple files has been replaced with the correct name "Cooperative Cuisine". These changes are reflected in the main code documentation, pygame_2D visualization, item type enumeration, and the CHANGELOG.
items.py 19.37 KiB
"""
The game items that a player can hold.
They have methods that
- check if items can be combined (`Item.can_combine`): cooking equipment and ingredients, and so on,
- combine the items after a successful check (`Item.combine`),
- and a method to call the progress on the items (`Item.progress`)
All game items need to be specified in the `item_info.yml`.
The following classes are used for the base for all game items:
- `Item`: ingredients and meals.
- `CookingEquipment`: pots, pans, etc.
- `Plate`: clean and dirty plates.
The `ItemInfo` is the dataclass for the items in the `item_info.yml`.
Additionally, the `Effect` class is defined for the `Fire` effect.
## Code Documentation
"""
from __future__ import annotations
import collections
import dataclasses
import datetime
import logging
import uuid
from enum import Enum
from typing import Optional, TypedDict, TYPE_CHECKING, Literal, Set
from cooperative_cuisine.hooks import (
Hooks,
ON_ITEM_TRANSITION,
PROGRESS_FINISHED,
PROGRESS_STARTED,
CONTENT_READY,
)
from cooperative_cuisine.state_representation import (
ItemState,
CookingEquipmentState,
EffectState,
)
if TYPE_CHECKING:
from cooperative_cuisine.effects import EffectManager
log = logging.getLogger(__name__)
"""The logger for this module."""
ITEM_CATEGORY = "Item"
"""The string for the `category` value in the json state representation for all normal items."""
COOKING_EQUIPMENT_ITEM_CATEGORY = "ItemCookingEquipment"
"""The string for the `category` value in the json state representation for all cooking equipments."""
class EffectType(Enum):
"""Enumeration of what types of effects `Effect`s can have on counters and items."""
Unusable = "Unusable"
"""Fire effect makes counters and items unusable."""
class ItemType(Enum):
"""Enumeration of which types of items exists in *Cooperative Cuisine*"""
Ingredient = "Ingredient"
"""All ingredients and process ingredients."""
Meal = "Meal"
"""All combined ingredients that can be served."""
Equipment = "Equipment"
"""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
class ItemInfo:
"""Wrapper for the info in the `item_info.yml`.
Example:
A simple example for the tomato soup with 6 game items.
```yaml
CuttingBoard:
type: Equipment
Stove:
type: Equipment
Pot:
type: Equipment
equipment: Stove
Tomato:
type: Ingredient
ChoppedTomato:
type: Ingredient
needs: [ Tomato ]
seconds: 4.0
equipment: CuttingBoard
TomatoSoup:
type: Meal
needs: [ ChoppedTomato, ChoppedTomato, ChoppedTomato ]
seconds: 6.0
equipment: Pot
```
"""
type: ItemType = dataclasses.field(compare=False)
"""Type of the item. Either `Ingredient`, `Meal` or `Equipment`."""
name: str = dataclasses.field(compare=True)
"""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):
if self.seconds < 0.0:
raise ValueError(
f"Expected seconds >= 0 for item '{self.name}', but got {self.seconds} in item info"
)
self.type = ItemType(self.type)
if self.effect_type:
self.effect_type = EffectType(self.effect_type)
class ActiveTransitionTypedDict(TypedDict):
"""The values in the active transitions dicts of `CookingEquipment`."""
seconds: int | float
"""The needed seconds to progress for the transition."""
result: str | Item | Effect
"""The new name of the item after the transition."""
class Item:
"""Base class for game items which can be held by a player."""
item_category: Literal["Item"] | Literal["ItemCookingEquipment"] = ITEM_CATEGORY
"""Class dependent category (is changed for the `CookingEquipment` class). """
def __init__(
self, name: str, item_info: ItemInfo, uid: str = None, *args, **kwargs
):
"""Constructor for Item.
Args:
name (str): The name of the item.
item_info (ItemInfo): The information about the item from the `item_info.yml` config.
uid (str, optional): A unique identifier for the item. Defaults to None.
*args: Variable length argument list.
**kwargs: Arbitrary keyword arguments.
"""
self.name: str = self.__class__.__name__ if name is None else name
"""The name of the item, e.g., `Tomato` or `ChoppedTomato`"""
self.item_info: ItemInfo = item_info
"""The information about the item from the `item_info.yml` config."""
self.progress_equipment: str | None = None
"""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.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."""
@property
def extra_repr(self) -> str | Literal[""]:
"""Additional string to add to the representation of the item (in __repr__)."""
return ""
def __repr__(self):
if self.progress_equipment is None:
return f"{self.name}({self.extra_repr})"
else:
return f"{self.name}(progress={round(self.progress_percentage * 100, 2)}%,{self.extra_repr})"
def __eq__(self, other):
return other and self.name == other.name
def can_combine(self, other) -> bool:
"""Check if the item can be combined with the other. After it returned True the `combine` method is called."""
return False
def combine(self, other) -> Item | None:
"""Combine the item with another item based on possible transitions/needs."""
pass
def progress(self, equipment: str, percent: float):
"""Progresses the item process on the given equipment as long as it is not finished."""
if self.progress_equipment is None:
self.progress_equipment = equipment
if self.progress_equipment == equipment:
self.progress_percentage += percent
self.progress_percentage = min(self.progress_percentage, 1.0)
else:
log.warning(
f"{self.name} expected progress on {self.progress_equipment}, but got {percent * 100}% on {equipment}"
)
def reset(self):
"""Reset the progress."""
self.progress_equipment = None
self.progress_percentage = 0.0
self.inverse_progress = False
def to_dict(self) -> CookingEquipmentState | ItemState:
"""For the state representation. Only the relevant attributes are put into the dict."""
return {
"id": self.uuid,
"category": self.item_category,
"type": self.name,
"progress_percentage": self.progress_percentage,
"inverse_progress": self.inverse_progress,
"active_effects": [e.to_dict() for e in self.active_effects],
}
class CookingEquipment(Item):
"""Pot, Pan, ... that can hold items. It holds the progress of the content (e.g., the soup) in itself (
progress_percentage) and not in the items in the content list."""
item_category = COOKING_EQUIPMENT_ITEM_CATEGORY
def __init__(self, transitions: dict[str, ItemInfo], hook: Hooks, *args, **kwargs):
"""Constructor for CookingEquipment.
Args:
transitions: A dictionary that represents the transitions or steps needed to cook a meal or create another ingredient. The keys are the names of the transitions and the values are the ItemInfo objects that contain information about the transition.
*args: Variable length argument list.
**kwargs: Arbitrarily named keyword arguments.
"""
super().__init__(*args, **kwargs)
self.transitions: dict[str, ItemInfo] = transitions
"""What is needed to cook a meal / create another ingredient."""
self.active_transition: Optional[ActiveTransitionTypedDict] = None
"""The info how and when to convert the content_list to a new item."""
# TODO change content ready just to str (name of the item)?
self.content_ready: Item | None = None
"""Helper attribute that can have a ready meal which is also represented via it ingredients in the
content_list. But soups or other processed meals are not covered here. For a Burger or Salad, this attribute
is set."""
self.content_list: list[Item] = []
"""The items that the equipment holds."""
self.hook: Hooks = hook
"""Reference to the hook manager."""
log.debug(f"Initialize {self.name}: {self.transitions}")
for transition in self.transitions.values():
transition.recipe = collections.Counter(transition.needs)
@property
def extra_repr(self) -> str:
return f"{self.content_list}, {self.content_ready}"
def can_combine(self, other) -> bool:
# already cooking or nothing to combine
if other is None or (
isinstance(other, CookingEquipment) and not other.content_list
):
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:
other = [other]
# other extends ingredients for meal
ingredients = collections.Counter(
item.name for item in self.content_list + other
)
return any(ingredients <= recipe.recipe for recipe in self.transitions.values())
def combine(self, other) -> Item | None:
return_value = None
if isinstance(other, CookingEquipment):
self.content_list.extend(other.content_list)
return_value = other
other.reset_content()
other.reset()
elif isinstance(other, list):
self.content_list.extend(other)
else:
self.content_list.append(other)
self.check_active_transition()
return return_value
def can_progress(self) -> bool:
"""Check if the cooking equipment can progress items at all."""
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"]
if self.progress_percentage == 0.0:
self.hook(PROGRESS_STARTED, item=self)
super().progress(equipment=self.name, percent=percent)
if self.progress_percentage == 1.0:
if isinstance(self.active_transition["result"], Effect):
self.hook(
ON_ITEM_TRANSITION,
item=self,
result=self.active_transition["result"],
seconds=self.active_transition["seconds"],
)
self.active_transition[
"result"
].item_info.manager.register_active_effect(
self.active_transition["result"], self
)
else:
self.content_list = [self.active_transition["result"]]
self.hook(PROGRESS_FINISHED, item=self)
self.reset()
self.check_active_transition()
# todo set active transition for fire/burnt?
def check_active_transition(self):
"""Check if a new active transition is possible, if so, set a new active transition.
Checks if there is an active transition based on the current state of the content list and transitions dictionary.
If an active transition is found, it updates the necessary attributes accordingly.
"""
ingredients = collections.Counter(item.name for item in self.content_list)
for result, transition in self.transitions.items():
if transition.type == ItemType.Effect:
if set(ingredients.keys()).issubset(
transition.needs
) and not transition.manager.effect_is_active(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:
# TODO here hook?
if transition.seconds == 0:
self.hook(CONTENT_READY, result=result, before=self)
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
def reset_content(self):
"""Reset the content attributes after the content was picked up from the equipment."""
self.content_list = []
self.content_ready = None
def release(self) -> list[Item]:
"""Release the content when the player "picks up" the equipment with a plate in the hands"""
content = self.content_list
self.reset_content()
self.reset()
return content
def reset(self):
super().reset()
self.active_transition = None
def get_potential_meal(self) -> Item | None:
"""The meal that could be served depends on the attributes `content_ready` and `content_list`"""
if self.content_ready:
return self.content_ready
if len(self.content_list) == 1:
return self.content_list[0]
return None
def to_dict(self) -> CookingEquipmentState:
d = super().to_dict()
d.update(
{
"content_list": [c.to_dict() for c in self.content_list],
"content_ready": self.content_ready.to_dict()
if self.content_ready is not None
else None,
}
)
return d
class Plate(CookingEquipment):
"""The plate can have to states: clean and dirty. In the clean state it can hold content/other items."""
def __init__(
self,
transitions: dict[str, ItemInfo],
clean: bool,
hook: Hooks,
*args,
**kwargs,
):
"""Constructor for Plate.
Args:
transitions: A dictionary mapping meal names to ItemInfo objects representing the transitions for each meal.
clean: A boolean indicating whether the plate is clean or dirty.
*args: Additional positional arguments.
**kwargs: Additional keyword arguments.
"""
self.clean: bool = clean
"""If the plate is clean or dirty."""
self.meals: Set[str] = set(transitions.keys())
"""All meals (meal names) can be hold by a clean plate,"""
super().__init__(
name=self.create_name(),
transitions={k: v for k, v in transitions.items() if not v.equipment},
hook=hook,
*args,
**kwargs,
)
def progress(self, equipment: str, percent: float):
Item.progress(self, equipment, percent)
def create_name(self) -> str:
"""The name depends on the clean or dirty state of the plate. Clean plates are `Plate`, otherwise
`DirtyPlate`."""
return "Plate" if self.clean else "DirtyPlate"
def can_combine(self, other) -> bool:
if not super().can_combine(other):
if (
isinstance(other, CookingEquipment)
and len(other.content_list) == 1
and not self.content_list
and self.clean
):
# additional check for meals in the content list of another equipment,
# e.g., Soups which is not covered by the normal transition checks.
return other.content_list[0].name in self.meals
return False
elif self.clean:
return True
return False
# this is here, due to a circular import if it would be in the effects.py
class Effect:
"""Effects on counters and items. Like Fire."""
def __init__(self, name: str, item_info: ItemInfo, uid: str = None):
"""Constructor for Effect.
Args:
name (str): The name of the effect.
item_info (ItemInfo): The item info for the effect, including effect manager, effect type, etc.
uid (str, optional): The unique ID of the effect instance. Defaults to None.
"""
self.uuid: str = uuid.uuid4().hex if uid is None else uid
"""ID of the effect instance."""
self.name: str = name
"""Name of the effect"""
self.item_info: ItemInfo = item_info
"""Item info for the effect, effect manager, effect type, etc."""
self.progres_percentage: float = 0.0
"""For fire: how much the player still has to extinguish."""
def to_dict(self) -> EffectState:
"""Converts the current object to a dictionary representation.
Returns:
dict: A dictionary representation of the object.
"""
return {
"id": self.uuid,
"type": self.name,
"progress_percentage": self.progres_percentage,
"inverse_progress": True,
}