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
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.
Inherited Members
- enum.Enum
- name
- value
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.
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.
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.
Superior limit to the list of stacks before the transformation.
Inferior limit to the list of stacks before the transformation.
Inherited Members
- enum.Enum
- name
- value
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.
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.
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.
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?
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.