Transformation

Transformations are at the core of HierarchyCraft as each HierarchyCraft environment is defined by a list of transformations and an initial state.

Each transformation is composed of three optional elements:

  • Inventory changes: Either using an item (consuming some amount or not, with a min/max required or not) or yielding an item (creating some amount of it, with a min/max required or not) in any inventory of either the player, the current zone, the destination zone, or any specific zones.
  • Destination: If specified the agent will move to the destination zone when performing the transformation.
  • Zone: Zone to which the transformation is restricted. If not specified, the transformation can be done anywhere.

Examples

from hcraft.elements import Item, Stack, Zone
from hcraft.transformation import Transformation, Use, Yield, PLAYER, CURRENT_ZONE

Add an item in player inventory

DIRT = Item("dirt")
search_for_dirt = Transformation(
    "search_for_dirt",
    inventory_changes=[Yield(PLAYER, DIRT)],
)

Modify the player position

FOREST = Zone("forest")
move_to_forest = Transformation(
    "move_to_forest",
    destination=FOREST,
)

Restrict a transformation to a zone

WOOD = Item("wood")
search_for_wood = Transformation(
    "search_for_wood",
    inventory_changes=[Yield(PLAYER, WOOD)],
    zone=FOREST,
)

Modify the player inventory

PLANK = Item("plank")
craft_wood_plank = Transformation(
    "craft_wood_plank",
    inventory_changes=[
        Use(PLAYER, WOOD, consume=1),
        Yield(PLAYER, PLANK, 4)
    ],
)

Note the use of Stack to give a quantity > 1.

Modify the current zone's inventory

HOUSE = Item("house")  # Need 12 WOOD and 64 PLANK to
build_house = Transformation(
    "build_house",
    inventory_changes=[
        Use(PLAYER, WOOD, 12),
        Use(PLAYER, PLANK, 64),
        Yield(CURRENT_ZONE, HOUSE),
    ],
)

Move with a cost

TREETOPS = Zone("treetops")
LADDER = Item("ladder")
climb_tree = Transformation(
    "climb_tree",
    destination=TREETOPS,
    inventory_changes=[Use(PLAYER, LADDER, consume=1)],
    zone=FOREST,
)

Modify the destination's inventory

# Jump from treetops
CRATER = Item("crater")
jump_from_tree = Transformation(
    "jump_from_tree",
    destination=FOREST,
    inventory_changes=[Yield("destination", CRATER)],
    zone=TREETOPS,
)

Move with a required item and required item in zone

INSIDE_HOUSE = Zone("house")
DOOR = Item("door")
KEY = Item("key")
enter_house = Transformation(
    destination=INSIDE_HOUSE,
    inventory_changes=[
        Use(PLAYER, KEY),  # Ensure has key
        Use(CURRENT_ZONE, DOOR),  # Ensure has door
    ],
)

By removing and adding the same item, we make sure that the item is required to be in the inventory but is not consumed.

Modifiy any specific zones inventories

# What if there is a strange red button you can press ?
STRANGE_RED_BUTTON = Item("don't press me")
SPACE = Zone("space")
INCOMING_MISSILES = Item("incoming_missiles")
press_red_button = Transformation(
    "press_red_button",
    inventory_changes=[
        Use(CURRENT_ZONE, STRANGE_RED_BUTTON),  # Ensure has door
        Yield(SPACE, INCOMING_MISSILES),  # An 'absolute' specific zone
    ],
)

Note that the player may not see the effect of such a transformation, because the player only observe the current zone items.

  1"""# Transformation
  2
  3Transformations are at the core of HierarchyCraft as
  4each HierarchyCraft environment is defined by
  5a list of transformations and an initial state.
  6
  7Each **transformation** is composed of three optional elements:
  8
  9-   **Inventory changes**: Either
 10    using an item (consuming some amount or not, with a min/max required or not)
 11    or yielding an item (creating some amount of it, with a min/max required or not)
 12    in any inventory of either the player, the current zone,
 13    the destination zone, or any specific zones.
 14-   **Destination**: If specified the agent will move to the destination zone
 15    when performing the transformation.
 16-   **Zone**: Zone to which the transformation is restricted.
 17    If not specified, the transformation can be done anywhere.
 18
 19
 20## Examples
 21
 22```python
 23from hcraft.elements import Item, Stack, Zone
 24from hcraft.transformation import Transformation, Use, Yield, PLAYER, CURRENT_ZONE
 25```
 26
 27### Add an item in player inventory
 28```python
 29DIRT = Item("dirt")
 30search_for_dirt = Transformation(
 31    "search_for_dirt",
 32    inventory_changes=[Yield(PLAYER, DIRT)],
 33)
 34```
 35
 36### Modify the player position
 37```python
 38FOREST = Zone("forest")
 39move_to_forest = Transformation(
 40    "move_to_forest",
 41    destination=FOREST,
 42)
 43```
 44
 45### Restrict a transformation to a zone
 46```python
 47WOOD = Item("wood")
 48search_for_wood = Transformation(
 49    "search_for_wood",
 50    inventory_changes=[Yield(PLAYER, WOOD)],
 51    zone=FOREST,
 52)
 53```
 54
 55### Modify the player inventory
 56```python
 57PLANK = Item("plank")
 58craft_wood_plank = Transformation(
 59    "craft_wood_plank",
 60    inventory_changes=[
 61        Use(PLAYER, WOOD, consume=1),
 62        Yield(PLAYER, PLANK, 4)
 63    ],
 64)
 65```
 66Note the use of Stack to give a quantity > 1.
 67
 68### Modify the current zone's inventory
 69```python
 70HOUSE = Item("house")  # Need 12 WOOD and 64 PLANK to
 71build_house = Transformation(
 72    "build_house",
 73    inventory_changes=[
 74        Use(PLAYER, WOOD, 12),
 75        Use(PLAYER, PLANK, 64),
 76        Yield(CURRENT_ZONE, HOUSE),
 77    ],
 78)
 79```
 80
 81### Move with a cost
 82```python
 83TREETOPS = Zone("treetops")
 84LADDER = Item("ladder")
 85climb_tree = Transformation(
 86    "climb_tree",
 87    destination=TREETOPS,
 88    inventory_changes=[Use(PLAYER, LADDER, consume=1)],
 89    zone=FOREST,
 90)
 91```
 92
 93### Modify the destination's inventory
 94```python
 95# Jump from treetops
 96CRATER = Item("crater")
 97jump_from_tree = Transformation(
 98    "jump_from_tree",
 99    destination=FOREST,
100    inventory_changes=[Yield("destination", CRATER)],
101    zone=TREETOPS,
102)
103```
104
105### Move with a required item and required item in zone
106```python
107INSIDE_HOUSE = Zone("house")
108DOOR = Item("door")
109KEY = Item("key")
110enter_house = Transformation(
111    destination=INSIDE_HOUSE,
112    inventory_changes=[
113        Use(PLAYER, KEY),  # Ensure has key
114        Use(CURRENT_ZONE, DOOR),  # Ensure has door
115    ],
116)
117```
118By removing and adding the same item,
119we make sure that the item is required to be in the inventory but is not consumed.
120
121### Modifiy any specific zones inventories
122```python
123# What if there is a strange red button you can press ?
124STRANGE_RED_BUTTON = Item("don't press me")
125SPACE = Zone("space")
126INCOMING_MISSILES = Item("incoming_missiles")
127press_red_button = Transformation(
128    "press_red_button",
129    inventory_changes=[
130        Use(CURRENT_ZONE, STRANGE_RED_BUTTON),  # Ensure has door
131        Yield(SPACE, INCOMING_MISSILES),  # An 'absolute' specific zone
132    ],
133)
134```
135Note that the player may not see the effect of such a transformation,
136because the player only observe the current zone items.
137
138
139"""
140
141from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple, Union, Any
142from enum import Enum
143from dataclasses import dataclass
144
145import numpy as np
146
147from hcraft.elements import Item, Stack, Zone
148
149if TYPE_CHECKING:
150    from hcraft.env import HcraftState
151    from hcraft.world import World
152
153
154class InventoryOwner(Enum):
155    """Enumeration of possible owners of inventory changes."""
156
157    PLAYER = "player"
158    """The player inventory"""
159    CURRENT = "current_zone"
160    """The current zone inventory"""
161    DESTINATION = "destination"
162    """The destination zone inventory"""
163    ZONES = "zones"
164    """A specific zone inventory"""
165
166
167PLAYER = InventoryOwner.PLAYER
168CURRENT_ZONE = InventoryOwner.CURRENT
169DESTINATION = InventoryOwner.DESTINATION
170
171
172@dataclass()
173class Use:
174    """Use the given item in the given inventory."""
175
176    owner: Union[InventoryOwner, Zone]
177    """Owner of the inventory to change."""
178    item: Item
179    """Item to use."""
180    consume: int = 0
181    """Amout of the item to remove from the inventory. Defaults to 0."""
182    min: Optional[int] = None
183    """Minimum amout of the item *before* the transformation to be valid.
184
185    By default, min is 1 if consume is 0, else min=consume.
186
187    """
188    max: int = np.inf
189    """Maximum amout of the item *before* the transformation to be valid. Defaults to inf."""
190
191    def __post_init__(self):
192        if not isinstance(self.owner, Zone):
193            self.owner = InventoryOwner(self.owner)
194        if self.min is None:
195            self.min = self.consume if self.consume > 0 else 1
196
197
198@dataclass()
199class Yield:
200    """Yield the given item in the given inventory."""
201
202    owner: Union[InventoryOwner, Zone]
203    """Owner of the inventory to change."""
204    item: Item
205    """Item to yield."""
206    create: int = 1
207    """Amout of the item to create in the inventory. Defaults to 1."""
208    min: int = -np.inf
209    """Minimum amout of the item *before* the transformation to be valid. Defaults to -inf."""
210    max: int = np.inf
211    """Maximum amout of the item *before* the transformation to be valid. Defaults to inf."""
212
213    def __post_init__(self):
214        if not isinstance(self.owner, Zone):
215            self.owner = InventoryOwner(self.owner)
216
217
218class InventoryOperation(Enum):
219    """Enumeration of operations that can be done on an inventory."""
220
221    REMOVE = "remove"
222    """Remove the list of stacks."""
223    ADD = "add"
224    """Add the list of stacks."""
225    MAX = "max"
226    """Superior limit to the list of stacks *before* the transformation."""
227    MIN = "min"
228    """Inferior limit to the list of stacks *before* the transformation."""
229    APPLY = "apply"
230    """Effects of applying the transformation."""
231
232
233InventoryChange = Union[Use, Yield]
234InventoryChanges = Dict[
235    InventoryOperation,
236    Union[List[Union[Item, Stack]], Dict[Zone, List[Union[Item, Stack]]]],
237]
238InventoryOperations = Dict[InventoryOperation, np.ndarray]
239
240
241class Transformation:
242    """The building blocks of every HierarchyCraft environment.
243
244    A list of transformations is what defines each HierarchyCraft environement.
245    Transformation becomes the available actions and all available transitions of the environment.
246
247    Each transformation defines changes of:
248
249    * the player inventory
250    * the player position to a given destination
251    * the current zone inventory
252    * the destination zone inventory (if a destination is specified).
253    * all specific zones inventories
254
255    Each inventory change is a list of removed (-) and added (+) Stack.
256
257    If specified, they may be restricted to only a subset of valid zones,
258    all zones are valid by default.
259
260    A Transformation can only be applied if valid in the given state.
261    A transformation is only valid if the player in a valid zone
262    and all relevant inventories have enough items to be removed *before* adding new items.
263
264    The picture bellow illustrates the impact of
265    an example transformation on a given `hcraft.HcraftState`:
266    <img
267        src="https://raw.githubusercontent.com/IRLL/HierarchyCraft/master/docs/images/hcraft_transformation.png"
268    width="90%"/>
269
270    In this example, when applied, the transformation will:
271
272    * <span style="color:red">(-)</span>
273        Remove 1 item "0", then <span style="color:red">(+)</span>
274        Add 4 item "3" in the <span style="color:red">player inventory</span>.
275    * Update the <span style="color:gray">player position</span>
276        from the <span style="color:green">current zone</span> "1".
277        to the <span style="color:orange">destination zone</span> "3".
278    * <span style="color:green">(-)</span>
279        Remove 2 zone item "0" and 1 zone item "1", then <span style="color:green">(+)</span>
280        Add 1 item "1" in the <span style="color:green">current zone</span> inventory.
281    * <span style="color:orange">(-)</span>
282        Remove 1 zone item "2", then <span style="color:orange">(+)</span>
283        Add 1 item "0" in the <span style="color:orange">destination zone</span> inventory.
284    * <span style="color:blue">(-)</span>
285        Remove 1 zone item "0" in the zone "1" inventory
286        and 2 zone item "2" in the zone "2" inventory,
287        then <span style="color:blue">(+)</span>
288        Add 1 zone item "1" in the zone "0" inventory
289        and 1 zone item "2" in the zone "1" inventory.
290
291    """
292
293    def __init__(
294        self,
295        name: Optional[str] = None,
296        destination: Optional[Zone] = None,
297        inventory_changes: Optional[List[InventoryChange]] = None,
298        zone: Optional[Zone] = None,
299    ) -> None:
300        """The building blocks of every HierarchyCraft environment.
301
302        Args:
303            name: Name given to the Transformation. If None use repr instead.
304                Defaults to None.
305            destination: Destination zone.
306                Defaults to None.
307            inventory_changes: List of inventory changes done by this transformation.
308                Defaults to None.
309            zone: Zone to which Transformation is restricted. Unrestricted if None.
310                Defaults to None.
311        """
312        self.destination = destination
313        self._destination = None
314
315        self.zone = zone
316        self._zone = None
317
318        self._changes_list = inventory_changes
319        self.inventory_changes = _format_inventory_changes(inventory_changes)
320        self._inventory_operations: Optional[
321            Dict[InventoryOwner, InventoryOperations]
322        ] = None
323
324        self.name = name if name is not None else self.__repr__()
325
326    def apply(
327        self,
328        player_inventory: np.ndarray,
329        position: np.ndarray,
330        zones_inventories: np.ndarray,
331    ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
332        """Apply the transformation in place on the given state."""
333
334        for owner, operations in self._inventory_operations.items():
335            operation_arr = operations[InventoryOperation.APPLY]
336            if operation_arr is not None:
337                _update_inventory(
338                    owner,
339                    player_inventory,
340                    position,
341                    zones_inventories,
342                    self._destination,
343                    operation_arr,
344                )
345        if self._destination is not None:
346            position[...] = self._destination
347
348    def is_valid(self, state: "HcraftState") -> bool:
349        """Is the transformation valid in the given state?"""
350        if not self._is_valid_position(state.position):
351            return False
352        if not self._is_valid_player_inventory(state.player_inventory):
353            return False
354        if not self._is_valid_zones_inventory(state.zones_inventories, state.position):
355            return False
356        return True
357
358    def build(self, world: "World") -> None:
359        """Build the transformation array operations on the given world."""
360        self._build_destination_op(world)
361        self._build_inventory_ops(world)
362        self._build_zones_op(world)
363
364    def get_changes(
365        self, owner: InventoryOwner, operation: InventoryOperation, default: Any = None
366    ) -> Optional[Union[List[Stack], Dict[Zone, List[Stack]]]]:
367        """Get individual changes for a given owner and a given operation.
368
369        Args:
370            owner: Owner of the inventory changes to get.
371            operation: Operation on the inventory to get.
372
373        Returns:
374            Changes of the inventory of the given owner with the given operation.
375        """
376        owner = InventoryOwner(owner)
377        operation = InventoryOperation(operation)
378        operations = self.inventory_changes.get(owner, {})
379        return operations.get(operation, default)
380
381    def production(self, owner: InventoryOwner) -> Set["Item"]:
382        """Set of produced items for the given owner by this transformation."""
383        return self._relevant_items_changed(owner, InventoryOperation.ADD)
384
385    def consumption(self, owner: InventoryOwner) -> Set["Item"]:
386        """Set of consumed items for the given owner by this transformation."""
387        return self._relevant_items_changed(owner, InventoryOperation.REMOVE)
388
389    def min_required(self, owner: InventoryOwner) -> Set["Item"]:
390        """Set of items for which a minimum is required by this transformation
391        for the given owner."""
392        return self._relevant_items_changed(owner, InventoryOperation.MIN)
393
394    def max_required(self, owner: InventoryOwner) -> Set["Item"]:
395        """Set of items for which a maximum is required by this transformation
396        for the given owner."""
397        return self._relevant_items_changed(owner, InventoryOperation.MAX)
398
399    @property
400    def produced_zones_items(self) -> Set["Item"]:
401        """Set of produced zones items by this transformation."""
402        return (
403            self.production(CURRENT_ZONE)
404            | self.production(DESTINATION)
405            | self.production(InventoryOwner.ZONES)
406        )
407
408    @property
409    def consumed_zones_items(self) -> Set["Item"]:
410        """Set of consumed zones items by this transformation."""
411        return (
412            self.consumption(CURRENT_ZONE)
413            | self.consumption(DESTINATION)
414            | self.consumption(InventoryOwner.ZONES)
415        )
416
417    @property
418    def min_required_zones_items(self) -> Set["Item"]:
419        """Set of zone items for which a minimum is required by this transformation."""
420        return (
421            self.min_required(CURRENT_ZONE)
422            | self.min_required(DESTINATION)
423            | self.min_required(InventoryOwner.ZONES)
424        )
425
426    @property
427    def max_required_zones_items(self) -> Set["Item"]:
428        """Set of zone items for which a maximum is required by this transformation."""
429        return (
430            self.max_required(CURRENT_ZONE)
431            | self.max_required(DESTINATION)
432            | self.max_required(InventoryOwner.ZONES)
433        )
434
435    def _relevant_items_changed(
436        self, owner: InventoryOwner, operation: InventoryOperation
437    ):
438        added_stacks = self.get_changes(owner, operation)
439        items = set()
440
441        if added_stacks:
442            if owner is not InventoryOwner.ZONES:
443                return _items_from_stack_list(added_stacks)
444
445            for _zone, stacks in added_stacks.items():
446                items |= _items_from_stack_list(stacks)
447
448        return items
449
450    def _is_valid_position(self, position: np.ndarray):
451        if self._zone is not None and not np.any(np.multiply(self._zone, position)):
452            return False
453        if self._destination is not None and np.all(self._destination == position):
454            return False
455        return True
456
457    def _is_valid_inventory(
458        self,
459        inventory: np.ndarray,
460        added: Optional[np.ndarray],
461        removed: Optional[np.ndarray],
462        max_items: Optional[np.ndarray],
463        min_items: Optional[np.ndarray],
464    ):
465        added = 0 if added is None else added
466        removed = 0 if removed is None else removed
467        if max_items is not None and np.any(inventory > max_items):
468            return False
469        if min_items is not None and np.any(inventory < min_items):
470            return False
471        return True
472
473    def _is_valid_player_inventory(self, player_inventory: np.ndarray):
474        items_changes = self._inventory_operations.get(InventoryOwner.PLAYER, {})
475        added = items_changes.get(InventoryOperation.ADD, 0)
476        removed = items_changes.get(InventoryOperation.REMOVE)
477        max_items = items_changes.get(InventoryOperation.MAX)
478        min_items = items_changes.get(InventoryOperation.MIN)
479        return self._is_valid_inventory(
480            player_inventory, added, removed, max_items, min_items
481        )
482
483    def _is_valid_zones_inventory(
484        self, zones_inventories: np.ndarray, position: np.ndarray
485    ):
486        if zones_inventories.size == 0:
487            return True
488
489        # Specific zones operations
490        zones_changes = self._inventory_operations.get(InventoryOwner.ZONES, {})
491        zeros = np.zeros_like(zones_inventories)
492        added = zones_changes.get(InventoryOperation.ADD, zeros.copy())
493        removed = zones_changes.get(InventoryOperation.REMOVE, zeros.copy())
494        infs = np.inf * np.ones_like(zones_inventories)
495        max_items = zones_changes.get(InventoryOperation.MAX, infs.copy())
496        min_items = zones_changes.get(InventoryOperation.MIN, zeros.copy())
497
498        # Current zone
499        current_changes = self._inventory_operations.get(InventoryOwner.CURRENT, {})
500        current_slot = position.nonzero()[0]
501        added[current_slot] += current_changes.get(InventoryOperation.ADD, 0)
502        removed[current_slot] += current_changes.get(InventoryOperation.REMOVE, 0)
503        max_items[current_slot] = np.minimum(
504            max_items[current_slot],
505            current_changes.get(InventoryOperation.MAX, np.inf),
506        )
507        min_items[current_slot] = np.maximum(
508            min_items[current_slot],
509            current_changes.get(InventoryOperation.MIN, -np.inf),
510        )
511
512        # Destination
513        if self._destination is not None:
514            dest_changes = self._inventory_operations.get(
515                InventoryOwner.DESTINATION, {}
516            )
517            dest_slot = self._destination.nonzero()[0]
518            added[dest_slot] += dest_changes.get(InventoryOperation.ADD, 0)
519            removed[dest_slot] += dest_changes.get(InventoryOperation.REMOVE, 0)
520            max_items[dest_slot] = np.minimum(
521                max_items[dest_slot],
522                dest_changes.get(InventoryOperation.MAX, np.inf),
523            )
524            min_items[dest_slot] = np.maximum(
525                min_items[dest_slot],
526                dest_changes.get(InventoryOperation.MIN, -np.inf),
527            )
528
529        return self._is_valid_inventory(
530            zones_inventories, added, removed, max_items, min_items
531        )
532
533    def _build_destination_op(self, world: "World") -> None:
534        if self.destination is None:
535            return
536        self._destination = np.zeros(world.n_zones, dtype=np.int32)
537        self._destination[world.slot_from_zone(self.destination)] = 1
538
539    def _build_zones_op(self, world: "World") -> None:
540        if self.zone is None:
541            return
542        self._zone = np.zeros(world.n_zones, dtype=np.int32)
543        self._zone[world.slot_from_zone(self.zone)] = 1
544
545    def _build_inventory_ops(self, world: "World"):
546        self._inventory_operations = {}
547        for owner, operations in self.inventory_changes.items():
548            self._build_inventory_operation(owner, operations, world)
549        self._build_apply_operations()
550
551    def _build_inventory_operation(
552        self, owner: InventoryOwner, operations: InventoryChanges, world: "World"
553    ):
554        owner = InventoryOwner(owner)
555        if owner is InventoryOwner.PLAYER:
556            world_items_list = world.items
557        else:
558            world_items_list = world.zones_items
559
560        for operation, stacks in operations.items():
561            operation = InventoryOperation(operation)
562            default_value = 0
563            if operation is InventoryOperation.MAX:
564                default_value = np.inf
565            if owner is InventoryOwner.ZONES:
566                operation_arr = self._build_zones_items_op(
567                    stacks, world.zones, world.zones_items, default_value
568                )
569            else:
570                operation_arr = self._build_operation_array(
571                    stacks, world_items_list, default_value
572                )
573            if owner not in self._inventory_operations:
574                self._inventory_operations[owner] = {}
575            self._inventory_operations[owner][operation] = operation_arr
576
577    def _build_apply_operations(self):
578        for owner, operations in self._inventory_operations.items():
579            apply_op = InventoryOperation.APPLY
580            apply_arr = _build_apply_operation_array(operations)
581            self._inventory_operations[owner][apply_op] = apply_arr
582
583    def _build_operation_array(
584        self,
585        stacks: List[Stack],
586        world_items_list: List["Item"],
587        default_value: int = 0,
588    ) -> np.ndarray:
589        operation = default_value * np.ones(len(world_items_list), dtype=np.int32)
590        for stack in stacks:
591            item_slot = world_items_list.index(stack.item)
592            operation[item_slot] = stack.quantity
593        return operation
594
595    def _build_zones_items_op(
596        self,
597        stacks_per_zone: Dict[Zone, List["Stack"]],
598        zones: List[Zone],
599        zones_items: List["Item"],
600        default_value: float = 0.0,
601    ) -> np.ndarray:
602        operation = default_value * np.ones(
603            (len(zones), len(zones_items)), dtype=np.int32
604        )
605        for zone, stacks in stacks_per_zone.items():
606            zone_slot = zones.index(zone)
607            for stack in stacks:
608                item_slot = zones_items.index(stack.item)
609                operation[zone_slot, item_slot] = stack.quantity
610        return operation
611
612    def __str__(self) -> str:
613        return self.name
614
615    def __repr__(self) -> str:
616        return f"{self._preconditions_repr()}⟹{self._effects_repr()}"
617
618    def _preconditions_repr(self) -> str:
619        preconditions_text = ""
620
621        owners_brackets = {
622            PLAYER: ".",
623            CURRENT_ZONE: "Zone(.)",
624            DESTINATION: "Dest(.)",
625        }
626
627        for owner in InventoryOwner:
628            if owner is InventoryOwner.ZONES:
629                continue
630            owner_texts = []
631            owner_texts += _stacks_precontions_str(
632                self.get_changes(owner, InventoryOperation.MIN),
633                symbol="≥",
634            )
635            owner_texts += _stacks_precontions_str(
636                self.get_changes(owner, InventoryOperation.MAX),
637                symbol="≤",
638            )
639            stacks_text = ",".join(owner_texts)
640            if not owner_texts:
641                continue
642            if preconditions_text:
643                preconditions_text += " "
644            preconditions_text += owners_brackets[owner].replace(".", stacks_text)
645
646        zones_specific_ops: Dict[Zone, Dict[InventoryOperation, List[Stack]]] = {}
647        for op, zones_stacks in self.inventory_changes.get(
648            InventoryOwner.ZONES, {}
649        ).items():
650            for zone, stacks in zones_stacks.items():
651                if zone not in zones_specific_ops:
652                    zones_specific_ops[zone] = {}
653                if op not in zones_specific_ops[zone]:
654                    zones_specific_ops[zone][op] = []
655                zones_specific_ops[zone][op] += stacks
656
657        for zone, operations in zones_specific_ops.items():
658            owner_texts = []
659            owner_texts += _stacks_precontions_str(
660                operations.get(InventoryOperation.MIN, []),
661                symbol="≥",
662            )
663            owner_texts += _stacks_precontions_str(
664                operations.get(InventoryOperation.MAX, []),
665                symbol="≤",
666            )
667            stacks_text = ",".join(owner_texts)
668            if not owner_texts:
669                continue
670            if preconditions_text:
671                preconditions_text += " "
672            preconditions_text += f"{zone.name}({stacks_text})"
673
674        if self.zone is not None:
675            if preconditions_text:
676                preconditions_text += " "
677            preconditions_text += f"| at {self.zone.name}"
678
679        if preconditions_text:
680            preconditions_text += " "
681
682        return preconditions_text
683
684    def _effects_repr(self) -> str:
685        effects_text = ""
686        owners_brackets = {
687            PLAYER: ".",
688            CURRENT_ZONE: "Zone(.)",
689            DESTINATION: "Dest(.)",
690        }
691
692        for owner in InventoryOwner:
693            if owner is InventoryOwner.ZONES:
694                continue
695            owner_texts = []
696            owner_texts += _stacks_effects_str(
697                self.get_changes(owner, InventoryOperation.REMOVE),
698                stack_prefix="-",
699            )
700            owner_texts += _stacks_effects_str(
701                self.get_changes(owner, InventoryOperation.ADD),
702                stack_prefix="+",
703            )
704            stacks_text = ",".join(owner_texts)
705            if not owner_texts:
706                continue
707            effects_text += " "
708            effects_text += owners_brackets[owner].replace(".", stacks_text)
709
710        zones_specific_ops: Dict[Zone, Dict[InventoryOperation, List[Stack]]] = {}
711        for op, zones_stacks in self.inventory_changes.get(
712            InventoryOwner.ZONES, {}
713        ).items():
714            for zone, stacks in zones_stacks.items():
715                if zone not in zones_specific_ops:
716                    zones_specific_ops[zone] = {}
717                if op not in zones_specific_ops[zone]:
718                    zones_specific_ops[zone][op] = []
719                zones_specific_ops[zone][op] += stacks
720
721        for zone, operations in zones_specific_ops.items():
722            owner_texts = []
723            owner_texts += _stacks_effects_str(
724                operations.get(InventoryOperation.REMOVE, []),
725                stack_prefix="-",
726            )
727            owner_texts += _stacks_effects_str(
728                operations.get(InventoryOperation.ADD, []),
729                stack_prefix="+",
730            )
731            stacks_text = ",".join(owner_texts)
732            if not owner_texts:
733                continue
734            effects_text += " "
735            effects_text += f"{zone.name}({stacks_text})"
736
737        if self.destination is not None:
738            effects_text += " "
739            effects_text += f"| at {self.destination.name}"
740
741        return effects_text
742
743
744def _update_inventory(
745    owner: InventoryOwner,
746    player_inventory: np.ndarray,
747    position: np.ndarray,
748    zones_inventories: np.ndarray,
749    destination: np.ndarray,
750    operation_arr: np.ndarray,
751):
752    position_slot: int = position.nonzero()[0]
753    if owner is PLAYER:
754        player_inventory[...] += operation_arr
755    elif owner is CURRENT_ZONE:
756        zones_inventories[position_slot, :] += operation_arr
757    elif owner is DESTINATION:
758        destination_slot: int = destination.nonzero()[0]
759        zones_inventories[destination_slot, :] += operation_arr
760    elif owner is InventoryOwner.ZONES:
761        zones_inventories[...] += operation_arr
762    else:
763        raise NotImplementedError
764
765
766def _build_apply_operation_array(
767    operations: InventoryOperations,
768) -> Optional[np.ndarray]:
769    apply_operation = None
770    if InventoryOperation.ADD in operations:
771        add_op = operations[InventoryOperation.ADD]
772        if apply_operation is None:
773            apply_operation = np.zeros_like(add_op)
774        apply_operation += add_op
775    if InventoryOperation.REMOVE in operations:
776        rem_op = operations[InventoryOperation.REMOVE]
777        if apply_operation is None:
778            apply_operation = np.zeros_like(rem_op)
779        apply_operation -= rem_op
780    return apply_operation
781
782
783def _stacks_effects_str(
784    stacks: Optional[List["Stack"]],
785    prefix: str = "",
786    suffix: str = "",
787    stack_prefix: str = "",
788) -> List[str]:
789    strings = []
790    if not stacks:
791        return strings
792    strings.append(_unstacked_str(stacks, prefix, suffix, stack_prefix))
793    return strings
794
795
796def _stacks_precontions_str(
797    stacks: Optional[List["Stack"]],
798    prefix: str = "",
799    suffix: str = "",
800    symbol: str = "",
801) -> List[str]:
802    strings = []
803    if not stacks:
804        return strings
805    strings.append(_unstacked_condition_str(stacks, prefix, suffix, symbol))
806    return strings
807
808
809def _unstacked_condition_str(
810    stacks: List["Stack"], prefix: str = "", suffix: str = "", symbol: str = ""
811):
812    items_text = ",".join(
813        [f"{stack.item.name}{symbol}{stack.quantity}" for stack in stacks]
814    )
815    return f"{prefix}{items_text}{suffix}"
816
817
818def _unstacked_str(
819    stacks: List["Stack"], prefix: str = "", suffix: str = "", stack_prefix: str = ""
820):
821    items_text = ",".join([f"{stack_prefix}{stack}" for stack in stacks])
822    return f"{prefix}{items_text}{suffix}"
823
824
825def _append_changes(
826    dict_of_changes: Dict[InventoryOwner, InventoryChanges],
827    change: InventoryChange,
828    zone: Optional[Zone] = None,
829):
830    owner = change.owner
831    if zone is not None:
832        owner = InventoryOwner.ZONES
833
834    if owner not in dict_of_changes:
835        dict_of_changes[owner] = {}
836
837    def _append_stack(operation: InventoryOperation, stack: Stack):
838        if operation not in dict_of_changes[owner]:
839            dict_of_changes[owner][operation] = []
840            if zone is not None:
841                dict_of_changes[owner][operation] = {}
842
843        if zone is None:
844            dict_of_changes[owner][operation].append(stack)
845            return
846
847        if zone not in dict_of_changes[owner][operation]:
848            dict_of_changes[owner][operation][zone] = []
849        dict_of_changes[owner][operation][zone].append(stack)
850
851    if change.min > -np.inf:
852        min_stack = Stack(change.item, change.min)
853        _append_stack(InventoryOperation.MIN, min_stack)
854
855    if change.max < np.inf:
856        max_stack = Stack(change.item, change.max)
857        _append_stack(InventoryOperation.MAX, max_stack)
858
859    if isinstance(change, Use):
860        if change.consume > 0:
861            rem_stack = Stack(change.item, change.consume)
862            _append_stack(InventoryOperation.REMOVE, rem_stack)
863
864    elif isinstance(change, Yield):
865        if change.create > 0:
866            add_stack = Stack(change.item, change.create)
867            _append_stack(InventoryOperation.ADD, add_stack)
868
869
870def _format_inventory_changes(
871    list_of_changes: Optional[List[InventoryChange]],
872) -> Dict[InventoryOwner, InventoryChanges]:
873    dict_of_stacks = {}
874    if list_of_changes is None:
875        return dict_of_stacks
876
877    for inv_change in list_of_changes:
878        zone = None
879        if isinstance(inv_change.owner, Zone):
880            zone = inv_change.owner
881        _append_changes(dict_of_stacks, inv_change, zone=zone)
882    return dict_of_stacks
883
884
885def _items_from_stack_list(stacks: List["Stack"]) -> Set["Item"]:
886    return set(stack.item for stack in stacks)

API Documentation

class InventoryOwner(enum.Enum):
155class InventoryOwner(Enum):
156    """Enumeration of possible owners of inventory changes."""
157
158    PLAYER = "player"
159    """The player inventory"""
160    CURRENT = "current_zone"
161    """The current zone inventory"""
162    DESTINATION = "destination"
163    """The destination zone inventory"""
164    ZONES = "zones"
165    """A specific zone inventory"""

Enumeration of possible owners of inventory changes.

PLAYER = <InventoryOwner.PLAYER: 'player'>

The player inventory

CURRENT = <InventoryOwner.CURRENT: 'current_zone'>

The current zone inventory

DESTINATION = <InventoryOwner.DESTINATION: 'destination'>

The destination zone inventory

ZONES = <InventoryOwner.ZONES: 'zones'>

A specific zone inventory

Inherited Members
enum.Enum
name
value
PLAYER = <InventoryOwner.PLAYER: 'player'>
CURRENT_ZONE = <InventoryOwner.CURRENT: 'current_zone'>
DESTINATION = <InventoryOwner.DESTINATION: 'destination'>
@dataclass()
class Use:
173@dataclass()
174class Use:
175    """Use the given item in the given inventory."""
176
177    owner: Union[InventoryOwner, Zone]
178    """Owner of the inventory to change."""
179    item: Item
180    """Item to use."""
181    consume: int = 0
182    """Amout of the item to remove from the inventory. Defaults to 0."""
183    min: Optional[int] = None
184    """Minimum amout of the item *before* the transformation to be valid.
185
186    By default, min is 1 if consume is 0, else min=consume.
187
188    """
189    max: int = np.inf
190    """Maximum amout of the item *before* the transformation to be valid. Defaults to inf."""
191
192    def __post_init__(self):
193        if not isinstance(self.owner, Zone):
194            self.owner = InventoryOwner(self.owner)
195        if self.min is None:
196            self.min = self.consume if self.consume > 0 else 1

Use the given item in the given inventory.

Use( owner: Union[InventoryOwner, hcraft.Zone], item: hcraft.Item, consume: int = 0, min: Optional[int] = None, max: int = inf)
owner: Union[InventoryOwner, hcraft.Zone]

Owner of the inventory to change.

item: hcraft.Item

Item to use.

consume: int = 0

Amout of the item to remove from the inventory. Defaults to 0.

min: Optional[int] = None

Minimum amout of the item before the transformation to be valid.

By default, min is 1 if consume is 0, else min=consume.

max: int = inf

Maximum amout of the item before the transformation to be valid. Defaults to inf.

@dataclass()
class Yield:
199@dataclass()
200class Yield:
201    """Yield the given item in the given inventory."""
202
203    owner: Union[InventoryOwner, Zone]
204    """Owner of the inventory to change."""
205    item: Item
206    """Item to yield."""
207    create: int = 1
208    """Amout of the item to create in the inventory. Defaults to 1."""
209    min: int = -np.inf
210    """Minimum amout of the item *before* the transformation to be valid. Defaults to -inf."""
211    max: int = np.inf
212    """Maximum amout of the item *before* the transformation to be valid. Defaults to inf."""
213
214    def __post_init__(self):
215        if not isinstance(self.owner, Zone):
216            self.owner = InventoryOwner(self.owner)

Yield the given item in the given inventory.

Yield( owner: Union[InventoryOwner, hcraft.Zone], item: hcraft.Item, create: int = 1, min: int = -inf, max: int = inf)
owner: Union[InventoryOwner, hcraft.Zone]

Owner of the inventory to change.

item: hcraft.Item

Item to yield.

create: int = 1

Amout of the item to create in the inventory. Defaults to 1.

min: int = -inf

Minimum amout of the item before the transformation to be valid. Defaults to -inf.

max: int = inf

Maximum amout of the item before the transformation to be valid. Defaults to inf.

class InventoryOperation(enum.Enum):
219class InventoryOperation(Enum):
220    """Enumeration of operations that can be done on an inventory."""
221
222    REMOVE = "remove"
223    """Remove the list of stacks."""
224    ADD = "add"
225    """Add the list of stacks."""
226    MAX = "max"
227    """Superior limit to the list of stacks *before* the transformation."""
228    MIN = "min"
229    """Inferior limit to the list of stacks *before* the transformation."""
230    APPLY = "apply"
231    """Effects of applying the transformation."""

Enumeration of operations that can be done on an inventory.

REMOVE = <InventoryOperation.REMOVE: 'remove'>

Remove the list of stacks.

ADD = <InventoryOperation.ADD: 'add'>

Add the list of stacks.

MAX = <InventoryOperation.MAX: 'max'>

Superior limit to the list of stacks before the transformation.

MIN = <InventoryOperation.MIN: 'min'>

Inferior limit to the list of stacks before the transformation.

APPLY = <InventoryOperation.APPLY: 'apply'>

Effects of applying the transformation.

Inherited Members
enum.Enum
name
value
InventoryChange = typing.Union[Use, Yield]
InventoryChanges = typing.Dict[InventoryOperation, typing.Union[typing.List[typing.Union[hcraft.Item, hcraft.Stack]], typing.Dict[hcraft.Zone, typing.List[typing.Union[hcraft.Item, hcraft.Stack]]]]]
InventoryOperations = typing.Dict[InventoryOperation, numpy.ndarray]
class Transformation:
242class Transformation:
243    """The building blocks of every HierarchyCraft environment.
244
245    A list of transformations is what defines each HierarchyCraft environement.
246    Transformation becomes the available actions and all available transitions of the environment.
247
248    Each transformation defines changes of:
249
250    * the player inventory
251    * the player position to a given destination
252    * the current zone inventory
253    * the destination zone inventory (if a destination is specified).
254    * all specific zones inventories
255
256    Each inventory change is a list of removed (-) and added (+) Stack.
257
258    If specified, they may be restricted to only a subset of valid zones,
259    all zones are valid by default.
260
261    A Transformation can only be applied if valid in the given state.
262    A transformation is only valid if the player in a valid zone
263    and all relevant inventories have enough items to be removed *before* adding new items.
264
265    The picture bellow illustrates the impact of
266    an example transformation on a given `hcraft.HcraftState`:
267    <img
268        src="https://raw.githubusercontent.com/IRLL/HierarchyCraft/master/docs/images/hcraft_transformation.png"
269    width="90%"/>
270
271    In this example, when applied, the transformation will:
272
273    * <span style="color:red">(-)</span>
274        Remove 1 item "0", then <span style="color:red">(+)</span>
275        Add 4 item "3" in the <span style="color:red">player inventory</span>.
276    * Update the <span style="color:gray">player position</span>
277        from the <span style="color:green">current zone</span> "1".
278        to the <span style="color:orange">destination zone</span> "3".
279    * <span style="color:green">(-)</span>
280        Remove 2 zone item "0" and 1 zone item "1", then <span style="color:green">(+)</span>
281        Add 1 item "1" in the <span style="color:green">current zone</span> inventory.
282    * <span style="color:orange">(-)</span>
283        Remove 1 zone item "2", then <span style="color:orange">(+)</span>
284        Add 1 item "0" in the <span style="color:orange">destination zone</span> inventory.
285    * <span style="color:blue">(-)</span>
286        Remove 1 zone item "0" in the zone "1" inventory
287        and 2 zone item "2" in the zone "2" inventory,
288        then <span style="color:blue">(+)</span>
289        Add 1 zone item "1" in the zone "0" inventory
290        and 1 zone item "2" in the zone "1" inventory.
291
292    """
293
294    def __init__(
295        self,
296        name: Optional[str] = None,
297        destination: Optional[Zone] = None,
298        inventory_changes: Optional[List[InventoryChange]] = None,
299        zone: Optional[Zone] = None,
300    ) -> None:
301        """The building blocks of every HierarchyCraft environment.
302
303        Args:
304            name: Name given to the Transformation. If None use repr instead.
305                Defaults to None.
306            destination: Destination zone.
307                Defaults to None.
308            inventory_changes: List of inventory changes done by this transformation.
309                Defaults to None.
310            zone: Zone to which Transformation is restricted. Unrestricted if None.
311                Defaults to None.
312        """
313        self.destination = destination
314        self._destination = None
315
316        self.zone = zone
317        self._zone = None
318
319        self._changes_list = inventory_changes
320        self.inventory_changes = _format_inventory_changes(inventory_changes)
321        self._inventory_operations: Optional[
322            Dict[InventoryOwner, InventoryOperations]
323        ] = None
324
325        self.name = name if name is not None else self.__repr__()
326
327    def apply(
328        self,
329        player_inventory: np.ndarray,
330        position: np.ndarray,
331        zones_inventories: np.ndarray,
332    ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
333        """Apply the transformation in place on the given state."""
334
335        for owner, operations in self._inventory_operations.items():
336            operation_arr = operations[InventoryOperation.APPLY]
337            if operation_arr is not None:
338                _update_inventory(
339                    owner,
340                    player_inventory,
341                    position,
342                    zones_inventories,
343                    self._destination,
344                    operation_arr,
345                )
346        if self._destination is not None:
347            position[...] = self._destination
348
349    def is_valid(self, state: "HcraftState") -> bool:
350        """Is the transformation valid in the given state?"""
351        if not self._is_valid_position(state.position):
352            return False
353        if not self._is_valid_player_inventory(state.player_inventory):
354            return False
355        if not self._is_valid_zones_inventory(state.zones_inventories, state.position):
356            return False
357        return True
358
359    def build(self, world: "World") -> None:
360        """Build the transformation array operations on the given world."""
361        self._build_destination_op(world)
362        self._build_inventory_ops(world)
363        self._build_zones_op(world)
364
365    def get_changes(
366        self, owner: InventoryOwner, operation: InventoryOperation, default: Any = None
367    ) -> Optional[Union[List[Stack], Dict[Zone, List[Stack]]]]:
368        """Get individual changes for a given owner and a given operation.
369
370        Args:
371            owner: Owner of the inventory changes to get.
372            operation: Operation on the inventory to get.
373
374        Returns:
375            Changes of the inventory of the given owner with the given operation.
376        """
377        owner = InventoryOwner(owner)
378        operation = InventoryOperation(operation)
379        operations = self.inventory_changes.get(owner, {})
380        return operations.get(operation, default)
381
382    def production(self, owner: InventoryOwner) -> Set["Item"]:
383        """Set of produced items for the given owner by this transformation."""
384        return self._relevant_items_changed(owner, InventoryOperation.ADD)
385
386    def consumption(self, owner: InventoryOwner) -> Set["Item"]:
387        """Set of consumed items for the given owner by this transformation."""
388        return self._relevant_items_changed(owner, InventoryOperation.REMOVE)
389
390    def min_required(self, owner: InventoryOwner) -> Set["Item"]:
391        """Set of items for which a minimum is required by this transformation
392        for the given owner."""
393        return self._relevant_items_changed(owner, InventoryOperation.MIN)
394
395    def max_required(self, owner: InventoryOwner) -> Set["Item"]:
396        """Set of items for which a maximum is required by this transformation
397        for the given owner."""
398        return self._relevant_items_changed(owner, InventoryOperation.MAX)
399
400    @property
401    def produced_zones_items(self) -> Set["Item"]:
402        """Set of produced zones items by this transformation."""
403        return (
404            self.production(CURRENT_ZONE)
405            | self.production(DESTINATION)
406            | self.production(InventoryOwner.ZONES)
407        )
408
409    @property
410    def consumed_zones_items(self) -> Set["Item"]:
411        """Set of consumed zones items by this transformation."""
412        return (
413            self.consumption(CURRENT_ZONE)
414            | self.consumption(DESTINATION)
415            | self.consumption(InventoryOwner.ZONES)
416        )
417
418    @property
419    def min_required_zones_items(self) -> Set["Item"]:
420        """Set of zone items for which a minimum is required by this transformation."""
421        return (
422            self.min_required(CURRENT_ZONE)
423            | self.min_required(DESTINATION)
424            | self.min_required(InventoryOwner.ZONES)
425        )
426
427    @property
428    def max_required_zones_items(self) -> Set["Item"]:
429        """Set of zone items for which a maximum is required by this transformation."""
430        return (
431            self.max_required(CURRENT_ZONE)
432            | self.max_required(DESTINATION)
433            | self.max_required(InventoryOwner.ZONES)
434        )
435
436    def _relevant_items_changed(
437        self, owner: InventoryOwner, operation: InventoryOperation
438    ):
439        added_stacks = self.get_changes(owner, operation)
440        items = set()
441
442        if added_stacks:
443            if owner is not InventoryOwner.ZONES:
444                return _items_from_stack_list(added_stacks)
445
446            for _zone, stacks in added_stacks.items():
447                items |= _items_from_stack_list(stacks)
448
449        return items
450
451    def _is_valid_position(self, position: np.ndarray):
452        if self._zone is not None and not np.any(np.multiply(self._zone, position)):
453            return False
454        if self._destination is not None and np.all(self._destination == position):
455            return False
456        return True
457
458    def _is_valid_inventory(
459        self,
460        inventory: np.ndarray,
461        added: Optional[np.ndarray],
462        removed: Optional[np.ndarray],
463        max_items: Optional[np.ndarray],
464        min_items: Optional[np.ndarray],
465    ):
466        added = 0 if added is None else added
467        removed = 0 if removed is None else removed
468        if max_items is not None and np.any(inventory > max_items):
469            return False
470        if min_items is not None and np.any(inventory < min_items):
471            return False
472        return True
473
474    def _is_valid_player_inventory(self, player_inventory: np.ndarray):
475        items_changes = self._inventory_operations.get(InventoryOwner.PLAYER, {})
476        added = items_changes.get(InventoryOperation.ADD, 0)
477        removed = items_changes.get(InventoryOperation.REMOVE)
478        max_items = items_changes.get(InventoryOperation.MAX)
479        min_items = items_changes.get(InventoryOperation.MIN)
480        return self._is_valid_inventory(
481            player_inventory, added, removed, max_items, min_items
482        )
483
484    def _is_valid_zones_inventory(
485        self, zones_inventories: np.ndarray, position: np.ndarray
486    ):
487        if zones_inventories.size == 0:
488            return True
489
490        # Specific zones operations
491        zones_changes = self._inventory_operations.get(InventoryOwner.ZONES, {})
492        zeros = np.zeros_like(zones_inventories)
493        added = zones_changes.get(InventoryOperation.ADD, zeros.copy())
494        removed = zones_changes.get(InventoryOperation.REMOVE, zeros.copy())
495        infs = np.inf * np.ones_like(zones_inventories)
496        max_items = zones_changes.get(InventoryOperation.MAX, infs.copy())
497        min_items = zones_changes.get(InventoryOperation.MIN, zeros.copy())
498
499        # Current zone
500        current_changes = self._inventory_operations.get(InventoryOwner.CURRENT, {})
501        current_slot = position.nonzero()[0]
502        added[current_slot] += current_changes.get(InventoryOperation.ADD, 0)
503        removed[current_slot] += current_changes.get(InventoryOperation.REMOVE, 0)
504        max_items[current_slot] = np.minimum(
505            max_items[current_slot],
506            current_changes.get(InventoryOperation.MAX, np.inf),
507        )
508        min_items[current_slot] = np.maximum(
509            min_items[current_slot],
510            current_changes.get(InventoryOperation.MIN, -np.inf),
511        )
512
513        # Destination
514        if self._destination is not None:
515            dest_changes = self._inventory_operations.get(
516                InventoryOwner.DESTINATION, {}
517            )
518            dest_slot = self._destination.nonzero()[0]
519            added[dest_slot] += dest_changes.get(InventoryOperation.ADD, 0)
520            removed[dest_slot] += dest_changes.get(InventoryOperation.REMOVE, 0)
521            max_items[dest_slot] = np.minimum(
522                max_items[dest_slot],
523                dest_changes.get(InventoryOperation.MAX, np.inf),
524            )
525            min_items[dest_slot] = np.maximum(
526                min_items[dest_slot],
527                dest_changes.get(InventoryOperation.MIN, -np.inf),
528            )
529
530        return self._is_valid_inventory(
531            zones_inventories, added, removed, max_items, min_items
532        )
533
534    def _build_destination_op(self, world: "World") -> None:
535        if self.destination is None:
536            return
537        self._destination = np.zeros(world.n_zones, dtype=np.int32)
538        self._destination[world.slot_from_zone(self.destination)] = 1
539
540    def _build_zones_op(self, world: "World") -> None:
541        if self.zone is None:
542            return
543        self._zone = np.zeros(world.n_zones, dtype=np.int32)
544        self._zone[world.slot_from_zone(self.zone)] = 1
545
546    def _build_inventory_ops(self, world: "World"):
547        self._inventory_operations = {}
548        for owner, operations in self.inventory_changes.items():
549            self._build_inventory_operation(owner, operations, world)
550        self._build_apply_operations()
551
552    def _build_inventory_operation(
553        self, owner: InventoryOwner, operations: InventoryChanges, world: "World"
554    ):
555        owner = InventoryOwner(owner)
556        if owner is InventoryOwner.PLAYER:
557            world_items_list = world.items
558        else:
559            world_items_list = world.zones_items
560
561        for operation, stacks in operations.items():
562            operation = InventoryOperation(operation)
563            default_value = 0
564            if operation is InventoryOperation.MAX:
565                default_value = np.inf
566            if owner is InventoryOwner.ZONES:
567                operation_arr = self._build_zones_items_op(
568                    stacks, world.zones, world.zones_items, default_value
569                )
570            else:
571                operation_arr = self._build_operation_array(
572                    stacks, world_items_list, default_value
573                )
574            if owner not in self._inventory_operations:
575                self._inventory_operations[owner] = {}
576            self._inventory_operations[owner][operation] = operation_arr
577
578    def _build_apply_operations(self):
579        for owner, operations in self._inventory_operations.items():
580            apply_op = InventoryOperation.APPLY
581            apply_arr = _build_apply_operation_array(operations)
582            self._inventory_operations[owner][apply_op] = apply_arr
583
584    def _build_operation_array(
585        self,
586        stacks: List[Stack],
587        world_items_list: List["Item"],
588        default_value: int = 0,
589    ) -> np.ndarray:
590        operation = default_value * np.ones(len(world_items_list), dtype=np.int32)
591        for stack in stacks:
592            item_slot = world_items_list.index(stack.item)
593            operation[item_slot] = stack.quantity
594        return operation
595
596    def _build_zones_items_op(
597        self,
598        stacks_per_zone: Dict[Zone, List["Stack"]],
599        zones: List[Zone],
600        zones_items: List["Item"],
601        default_value: float = 0.0,
602    ) -> np.ndarray:
603        operation = default_value * np.ones(
604            (len(zones), len(zones_items)), dtype=np.int32
605        )
606        for zone, stacks in stacks_per_zone.items():
607            zone_slot = zones.index(zone)
608            for stack in stacks:
609                item_slot = zones_items.index(stack.item)
610                operation[zone_slot, item_slot] = stack.quantity
611        return operation
612
613    def __str__(self) -> str:
614        return self.name
615
616    def __repr__(self) -> str:
617        return f"{self._preconditions_repr()}⟹{self._effects_repr()}"
618
619    def _preconditions_repr(self) -> str:
620        preconditions_text = ""
621
622        owners_brackets = {
623            PLAYER: ".",
624            CURRENT_ZONE: "Zone(.)",
625            DESTINATION: "Dest(.)",
626        }
627
628        for owner in InventoryOwner:
629            if owner is InventoryOwner.ZONES:
630                continue
631            owner_texts = []
632            owner_texts += _stacks_precontions_str(
633                self.get_changes(owner, InventoryOperation.MIN),
634                symbol="≥",
635            )
636            owner_texts += _stacks_precontions_str(
637                self.get_changes(owner, InventoryOperation.MAX),
638                symbol="≤",
639            )
640            stacks_text = ",".join(owner_texts)
641            if not owner_texts:
642                continue
643            if preconditions_text:
644                preconditions_text += " "
645            preconditions_text += owners_brackets[owner].replace(".", stacks_text)
646
647        zones_specific_ops: Dict[Zone, Dict[InventoryOperation, List[Stack]]] = {}
648        for op, zones_stacks in self.inventory_changes.get(
649            InventoryOwner.ZONES, {}
650        ).items():
651            for zone, stacks in zones_stacks.items():
652                if zone not in zones_specific_ops:
653                    zones_specific_ops[zone] = {}
654                if op not in zones_specific_ops[zone]:
655                    zones_specific_ops[zone][op] = []
656                zones_specific_ops[zone][op] += stacks
657
658        for zone, operations in zones_specific_ops.items():
659            owner_texts = []
660            owner_texts += _stacks_precontions_str(
661                operations.get(InventoryOperation.MIN, []),
662                symbol="≥",
663            )
664            owner_texts += _stacks_precontions_str(
665                operations.get(InventoryOperation.MAX, []),
666                symbol="≤",
667            )
668            stacks_text = ",".join(owner_texts)
669            if not owner_texts:
670                continue
671            if preconditions_text:
672                preconditions_text += " "
673            preconditions_text += f"{zone.name}({stacks_text})"
674
675        if self.zone is not None:
676            if preconditions_text:
677                preconditions_text += " "
678            preconditions_text += f"| at {self.zone.name}"
679
680        if preconditions_text:
681            preconditions_text += " "
682
683        return preconditions_text
684
685    def _effects_repr(self) -> str:
686        effects_text = ""
687        owners_brackets = {
688            PLAYER: ".",
689            CURRENT_ZONE: "Zone(.)",
690            DESTINATION: "Dest(.)",
691        }
692
693        for owner in InventoryOwner:
694            if owner is InventoryOwner.ZONES:
695                continue
696            owner_texts = []
697            owner_texts += _stacks_effects_str(
698                self.get_changes(owner, InventoryOperation.REMOVE),
699                stack_prefix="-",
700            )
701            owner_texts += _stacks_effects_str(
702                self.get_changes(owner, InventoryOperation.ADD),
703                stack_prefix="+",
704            )
705            stacks_text = ",".join(owner_texts)
706            if not owner_texts:
707                continue
708            effects_text += " "
709            effects_text += owners_brackets[owner].replace(".", stacks_text)
710
711        zones_specific_ops: Dict[Zone, Dict[InventoryOperation, List[Stack]]] = {}
712        for op, zones_stacks in self.inventory_changes.get(
713            InventoryOwner.ZONES, {}
714        ).items():
715            for zone, stacks in zones_stacks.items():
716                if zone not in zones_specific_ops:
717                    zones_specific_ops[zone] = {}
718                if op not in zones_specific_ops[zone]:
719                    zones_specific_ops[zone][op] = []
720                zones_specific_ops[zone][op] += stacks
721
722        for zone, operations in zones_specific_ops.items():
723            owner_texts = []
724            owner_texts += _stacks_effects_str(
725                operations.get(InventoryOperation.REMOVE, []),
726                stack_prefix="-",
727            )
728            owner_texts += _stacks_effects_str(
729                operations.get(InventoryOperation.ADD, []),
730                stack_prefix="+",
731            )
732            stacks_text = ",".join(owner_texts)
733            if not owner_texts:
734                continue
735            effects_text += " "
736            effects_text += f"{zone.name}({stacks_text})"
737
738        if self.destination is not None:
739            effects_text += " "
740            effects_text += f"| at {self.destination.name}"
741
742        return effects_text

The building blocks of every HierarchyCraft environment.

A list of transformations is what defines each HierarchyCraft environement. Transformation becomes the available actions and all available transitions of the environment.

Each transformation defines changes of:

  • the player inventory
  • the player position to a given destination
  • the current zone inventory
  • the destination zone inventory (if a destination is specified).
  • all specific zones inventories

Each inventory change is a list of removed (-) and added (+) Stack.

If specified, they may be restricted to only a subset of valid zones, all zones are valid by default.

A Transformation can only be applied if valid in the given state. A transformation is only valid if the player in a valid zone and all relevant inventories have enough items to be removed before adding new items.

The picture bellow illustrates the impact of an example transformation on a given hcraft.HcraftState:

In this example, when applied, the transformation will:

  • (-) Remove 1 item "0", then (+) Add 4 item "3" in the player inventory.
  • Update the player position from the current zone "1". to the destination zone "3".
  • (-) Remove 2 zone item "0" and 1 zone item "1", then (+) Add 1 item "1" in the current zone inventory.
  • (-) Remove 1 zone item "2", then (+) Add 1 item "0" in the destination zone inventory.
  • (-) Remove 1 zone item "0" in the zone "1" inventory and 2 zone item "2" in the zone "2" inventory, then (+) Add 1 zone item "1" in the zone "0" inventory and 1 zone item "2" in the zone "1" inventory.
Transformation( name: Optional[str] = None, destination: Optional[hcraft.Zone] = None, inventory_changes: Optional[List[Union[Use, Yield]]] = None, zone: Optional[hcraft.Zone] = None)
294    def __init__(
295        self,
296        name: Optional[str] = None,
297        destination: Optional[Zone] = None,
298        inventory_changes: Optional[List[InventoryChange]] = None,
299        zone: Optional[Zone] = None,
300    ) -> None:
301        """The building blocks of every HierarchyCraft environment.
302
303        Args:
304            name: Name given to the Transformation. If None use repr instead.
305                Defaults to None.
306            destination: Destination zone.
307                Defaults to None.
308            inventory_changes: List of inventory changes done by this transformation.
309                Defaults to None.
310            zone: Zone to which Transformation is restricted. Unrestricted if None.
311                Defaults to None.
312        """
313        self.destination = destination
314        self._destination = None
315
316        self.zone = zone
317        self._zone = None
318
319        self._changes_list = inventory_changes
320        self.inventory_changes = _format_inventory_changes(inventory_changes)
321        self._inventory_operations: Optional[
322            Dict[InventoryOwner, InventoryOperations]
323        ] = None
324
325        self.name = name if name is not None else self.__repr__()

The building blocks of every HierarchyCraft environment.

Arguments:
  • name: Name given to the Transformation. If None use repr instead. Defaults to None.
  • destination: Destination zone. Defaults to None.
  • inventory_changes: List of inventory changes done by this transformation. Defaults to None.
  • zone: Zone to which Transformation is restricted. Unrestricted if None. Defaults to None.
destination
zone
inventory_changes
name
def apply( self, player_inventory: numpy.ndarray, position: numpy.ndarray, zones_inventories: numpy.ndarray) -> Tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray]:
327    def apply(
328        self,
329        player_inventory: np.ndarray,
330        position: np.ndarray,
331        zones_inventories: np.ndarray,
332    ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
333        """Apply the transformation in place on the given state."""
334
335        for owner, operations in self._inventory_operations.items():
336            operation_arr = operations[InventoryOperation.APPLY]
337            if operation_arr is not None:
338                _update_inventory(
339                    owner,
340                    player_inventory,
341                    position,
342                    zones_inventories,
343                    self._destination,
344                    operation_arr,
345                )
346        if self._destination is not None:
347            position[...] = self._destination

Apply the transformation in place on the given state.

def is_valid(self, state: hcraft.HcraftState) -> bool:
349    def is_valid(self, state: "HcraftState") -> bool:
350        """Is the transformation valid in the given state?"""
351        if not self._is_valid_position(state.position):
352            return False
353        if not self._is_valid_player_inventory(state.player_inventory):
354            return False
355        if not self._is_valid_zones_inventory(state.zones_inventories, state.position):
356            return False
357        return True

Is the transformation valid in the given state?

def build(self, world: hcraft.world.World) -> None:
359    def build(self, world: "World") -> None:
360        """Build the transformation array operations on the given world."""
361        self._build_destination_op(world)
362        self._build_inventory_ops(world)
363        self._build_zones_op(world)

Build the transformation array operations on the given world.

def get_changes( self, owner: InventoryOwner, operation: InventoryOperation, default: Any = None) -> Union[List[hcraft.Stack], Dict[hcraft.Zone, List[hcraft.Stack]], NoneType]:
365    def get_changes(
366        self, owner: InventoryOwner, operation: InventoryOperation, default: Any = None
367    ) -> Optional[Union[List[Stack], Dict[Zone, List[Stack]]]]:
368        """Get individual changes for a given owner and a given operation.
369
370        Args:
371            owner: Owner of the inventory changes to get.
372            operation: Operation on the inventory to get.
373
374        Returns:
375            Changes of the inventory of the given owner with the given operation.
376        """
377        owner = InventoryOwner(owner)
378        operation = InventoryOperation(operation)
379        operations = self.inventory_changes.get(owner, {})
380        return operations.get(operation, default)

Get individual changes for a given owner and a given operation.

Arguments:
  • owner: Owner of the inventory changes to get.
  • operation: Operation on the inventory to get.
Returns:

Changes of the inventory of the given owner with the given operation.

def production( self, owner: InventoryOwner) -> Set[hcraft.Item]:
382    def production(self, owner: InventoryOwner) -> Set["Item"]:
383        """Set of produced items for the given owner by this transformation."""
384        return self._relevant_items_changed(owner, InventoryOperation.ADD)

Set of produced items for the given owner by this transformation.

def consumption( self, owner: InventoryOwner) -> Set[hcraft.Item]:
386    def consumption(self, owner: InventoryOwner) -> Set["Item"]:
387        """Set of consumed items for the given owner by this transformation."""
388        return self._relevant_items_changed(owner, InventoryOperation.REMOVE)

Set of consumed items for the given owner by this transformation.

def min_required( self, owner: InventoryOwner) -> Set[hcraft.Item]:
390    def min_required(self, owner: InventoryOwner) -> Set["Item"]:
391        """Set of items for which a minimum is required by this transformation
392        for the given owner."""
393        return self._relevant_items_changed(owner, InventoryOperation.MIN)

Set of items for which a minimum is required by this transformation for the given owner.

def max_required( self, owner: InventoryOwner) -> Set[hcraft.Item]:
395    def max_required(self, owner: InventoryOwner) -> Set["Item"]:
396        """Set of items for which a maximum is required by this transformation
397        for the given owner."""
398        return self._relevant_items_changed(owner, InventoryOperation.MAX)

Set of items for which a maximum is required by this transformation for the given owner.

produced_zones_items: Set[hcraft.Item]
400    @property
401    def produced_zones_items(self) -> Set["Item"]:
402        """Set of produced zones items by this transformation."""
403        return (
404            self.production(CURRENT_ZONE)
405            | self.production(DESTINATION)
406            | self.production(InventoryOwner.ZONES)
407        )

Set of produced zones items by this transformation.

consumed_zones_items: Set[hcraft.Item]
409    @property
410    def consumed_zones_items(self) -> Set["Item"]:
411        """Set of consumed zones items by this transformation."""
412        return (
413            self.consumption(CURRENT_ZONE)
414            | self.consumption(DESTINATION)
415            | self.consumption(InventoryOwner.ZONES)
416        )

Set of consumed zones items by this transformation.

min_required_zones_items: Set[hcraft.Item]
418    @property
419    def min_required_zones_items(self) -> Set["Item"]:
420        """Set of zone items for which a minimum is required by this transformation."""
421        return (
422            self.min_required(CURRENT_ZONE)
423            | self.min_required(DESTINATION)
424            | self.min_required(InventoryOwner.ZONES)
425        )

Set of zone items for which a minimum is required by this transformation.

max_required_zones_items: Set[hcraft.Item]
427    @property
428    def max_required_zones_items(self) -> Set["Item"]:
429        """Set of zone items for which a maximum is required by this transformation."""
430        return (
431            self.max_required(CURRENT_ZONE)
432            | self.max_required(DESTINATION)
433            | self.max_required(InventoryOwner.ZONES)
434        )

Set of zone items for which a maximum is required by this transformation.