plancraft 0.1.2__py3-none-any.whl → 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,212 @@
1
+ from typing import Optional
2
+
3
+ from plancraft.environments.actions import SymbolicAction
4
+ from plancraft.environments.recipes import (
5
+ RECIPES,
6
+ ShapedRecipe,
7
+ ShapelessRecipe,
8
+ SmeltingRecipe,
9
+ convert_ingredients_to_table,
10
+ )
11
+ from plancraft.environments.sampler import MAX_STACK_SIZE
12
+
13
+
14
+ class PseudoActionSpace:
15
+ def no_op(self):
16
+ return {
17
+ "inventory_command": (0, 0, 0),
18
+ }
19
+
20
+
21
+ class SymbolicPlancraft:
22
+ def __init__(self, inventory: list[dict] = [], recipes=RECIPES, **kwargs):
23
+ self.inventory = inventory
24
+ self.reset_state()
25
+ self.table_indexes = list(range(1, 10))
26
+ self.output_index = 0
27
+
28
+ self.action_space = PseudoActionSpace()
29
+
30
+ self.recipes = recipes
31
+
32
+ self.smelting_recipes = []
33
+
34
+ self.crafting_recipes = []
35
+
36
+ for recipe_list in recipes.values():
37
+ for recipe in recipe_list:
38
+ if isinstance(recipe, SmeltingRecipe):
39
+ self.smelting_recipes.append(recipe)
40
+ elif isinstance(recipe, (ShapelessRecipe, ShapedRecipe)):
41
+ self.crafting_recipes.append(recipe)
42
+
43
+ def reset_state(self):
44
+ self.state = {i: {"type": "air", "quantity": 0} for i in range(46)}
45
+ # initialise inventory
46
+ for item in self.inventory:
47
+ self.state[item["slot"]] = {
48
+ "type": item["type"],
49
+ "quantity": item["quantity"],
50
+ }
51
+
52
+ def step(self, action: Optional[SymbolicAction | dict]):
53
+ if action is None:
54
+ state_list = [
55
+ {"type": item["type"], "quantity": item["quantity"], "index": idx}
56
+ for idx, item in self.state.items()
57
+ ]
58
+ return {"inventory": state_list}, 0, False, {}
59
+
60
+ # action_dict = action.to_action_dict()
61
+ if not isinstance(action, dict):
62
+ action = action.to_action_dict()
63
+
64
+ if "inventory_command" in action:
65
+ # do inventory command (move)
66
+ slot, slot_to, quantity = action["inventory_command"]
67
+ self.move_item(slot, slot_to, quantity)
68
+ elif "smelt" in action:
69
+ # do smelt
70
+ slot, slot_to, quantity = action["smelt"]
71
+ self.smelt_item(slot, slot_to, quantity)
72
+ else:
73
+ raise ValueError("Invalid action")
74
+ # logger.warn("Cannot parse action for Symbolic action")
75
+
76
+ self.clean_state()
77
+
78
+ # convert to list for same format as minerl
79
+ state_list = [
80
+ {"type": item["type"], "quantity": item["quantity"], "index": idx}
81
+ for idx, item in self.state.items()
82
+ ]
83
+
84
+ return {"inventory": state_list}, 0, False, {}
85
+
86
+ def clean_state(self):
87
+ # reset slot type if quantity is 0
88
+ for i in self.state.keys():
89
+ if self.state[i]["quantity"] == 0:
90
+ self.state[i]["type"] = "air"
91
+
92
+ def move_item(self, slot_from: int, slot_to: int, quantity: int):
93
+ if slot_from == slot_to or quantity < 1 or slot_to == 0:
94
+ return
95
+ # slot outside of inventory
96
+ if slot_from not in self.state or slot_to not in self.state:
97
+ return
98
+ # not enough
99
+ if self.state[slot_from]["quantity"] < quantity:
100
+ return
101
+
102
+ item = self.state[slot_from]
103
+
104
+ # slot to is not empty or is the same type as item
105
+ if (self.state[slot_to]["type"] == "air") or (
106
+ self.state[slot_to]["quantity"] <= 0
107
+ ):
108
+ self.state[slot_to] = {"type": item["type"], "quantity": quantity}
109
+ self.state[slot_from]["quantity"] -= quantity
110
+ elif self.state[slot_to]["type"] == item["type"] and (
111
+ MAX_STACK_SIZE[item["type"]] >= self.state[slot_to]["quantity"] + quantity
112
+ ):
113
+ # check if the quantity exceeds the max stack size
114
+ self.state[slot_to]["quantity"] += quantity
115
+ self.state[slot_from]["quantity"] -= quantity
116
+ else:
117
+ return
118
+
119
+ # reset slot if quantity is 0
120
+ if self.state[slot_from]["quantity"] == 0:
121
+ self.state[slot_from] = {"type": "air", "quantity": 0}
122
+
123
+ # use up ingredients
124
+ if slot_from == 0:
125
+ self.use_ingredients()
126
+
127
+ # populate craft slot if ingredients in crafting table have changed
128
+ if slot_to < 10 or slot_from < 10:
129
+ self.populate_craft_slot_craft_item()
130
+
131
+ def smelt_item(self, slot_from: int, slot_to: int, quantity: int):
132
+ if quantity < 1 or slot_to == 0 or slot_from == slot_to or slot_from == 0:
133
+ return # skip if quantity is less than 1
134
+
135
+ if slot_from not in self.state or slot_to not in self.state:
136
+ return # handle slot out of bounds or invalid slot numbers
137
+
138
+ item = self.state[slot_from]
139
+ if item["quantity"] < quantity or item["type"] == "air":
140
+ return # skip if the slot from is empty or does not have enough items
141
+
142
+ for recipe in self.smelting_recipes:
143
+ if output := recipe.smelt(item["type"]):
144
+ output_type = output.item
145
+ # Check if the destination slot is empty or has the same type of item as the output
146
+ if self.state[slot_to]["type"] == "air":
147
+ self.state[slot_to] = {"type": output_type, "quantity": quantity}
148
+ self.state[slot_from]["quantity"] -= quantity
149
+ break
150
+ elif self.state[slot_to]["type"] == output_type and (
151
+ MAX_STACK_SIZE[output_type]
152
+ >= self.state[slot_to]["quantity"] + quantity
153
+ ): # assuming max stack size is 64
154
+ self.state[slot_to]["quantity"] += quantity
155
+ self.state[slot_from]["quantity"] -= quantity
156
+ break
157
+ else:
158
+ return # No space or type mismatch in slot_to
159
+
160
+ # Clean up if the source slot is depleted
161
+ if self.state[slot_from] == 0:
162
+ self.state[slot_from] = {"type": "air", "quantity": 0}
163
+
164
+ if slot_to < 10 or slot_from < 10:
165
+ self.populate_craft_slot_craft_item()
166
+
167
+ def populate_craft_slot_craft_item(self):
168
+ # get ingredients from crafting table
169
+ ingredients = []
170
+ for i in self.table_indexes:
171
+ if self.state[i]["type"] != "air" and self.state[i]["quantity"] > 0:
172
+ ingredients.append(self.state[i]["type"])
173
+ else:
174
+ ingredients.append(None)
175
+ table = convert_ingredients_to_table(ingredients)
176
+
177
+ # check if any of the crafting recipes match the ingredients
178
+ for recipe in self.crafting_recipes:
179
+ if result := recipe.craft(table):
180
+ result, indexes = result
181
+ self.ingredients_idxs = indexes
182
+ self.state[self.output_index] = {
183
+ "type": result.item,
184
+ "quantity": result.count,
185
+ }
186
+ return
187
+
188
+ self.ingredients_idxs = []
189
+ self.state[self.output_index] = {"type": "air", "quantity": 0}
190
+
191
+ def use_ingredients(self):
192
+ # remove used ingredients from crafting table
193
+ for idx in self.ingredients_idxs:
194
+ self.state[idx + 1]["quantity"] -= 1
195
+ if self.state[idx + 1]["quantity"] <= 0:
196
+ self.state[idx + 1] = {"type": "air", "quantity": 0}
197
+ self.ingredients_idxs = []
198
+
199
+ def reset(self):
200
+ self.reset_state()
201
+ return self.state
202
+
203
+ def fast_reset(self, new_inventory: list[dict]):
204
+ self.inventory = new_inventory
205
+ self.reset_state()
206
+ return self.state
207
+
208
+ def render(self):
209
+ print(f"state: {self.state}")
210
+
211
+ def close(self):
212
+ self.reset_state()
@@ -0,0 +1,10 @@
1
+ import json
2
+ import os
3
+
4
+
5
+ # originally mc_constants.1.16.json
6
+ path = os.path.join(os.path.dirname(__file__), "constants.json")
7
+ all_data = json.load(open(path))
8
+
9
+
10
+ ALL_ITEMS = [item["type"] for item in all_data["items"]]
@@ -0,0 +1,109 @@
1
+ import time
2
+
3
+ import networkx as nx
4
+
5
+ from plancraft.environments.recipes import RECIPES, BaseRecipe
6
+
7
+ RECIPE_GRAPH = nx.DiGraph()
8
+
9
+ for item, recipes in RECIPES.items():
10
+ for recipe in recipes:
11
+ RECIPE_GRAPH.add_node(recipe.result.item)
12
+ for ingredient in recipe.inputs:
13
+ RECIPE_GRAPH.add_node(ingredient)
14
+ RECIPE_GRAPH.add_edge(ingredient, recipe.result.item)
15
+
16
+
17
+ def get_ancestors(target: str):
18
+ return list(nx.ancestors(RECIPE_GRAPH, source=target))
19
+
20
+
21
+ def optimal_planner(
22
+ target: str,
23
+ inventory: dict[str, int],
24
+ steps=[],
25
+ best_steps=None,
26
+ max_steps=40,
27
+ timeout=30,
28
+ ) -> list[tuple[BaseRecipe, dict[str, int]]]:
29
+ """
30
+ Optimal planner for crafting the target item from the given inventory.
31
+
32
+ Uses depth-first search with memoization to find the shortest path of crafting steps.
33
+
34
+ Args:
35
+ target: The target item to craft.
36
+ inventory: The current inventory.
37
+ steps: The current path of crafting steps.
38
+ best_steps: The best path of crafting steps found so far.
39
+ max_steps: The maximum number of steps to take.
40
+ timeout: The maximum time to spend searching for a solution.
41
+
42
+ Returns:
43
+ list of tuples of (recipe, inventory) for each step in the optimal path.
44
+ """
45
+
46
+ memo = {}
47
+ # only look at recipes that are ancestors of the target
48
+ ancestors = get_ancestors(target)
49
+ # sort to put the closest ancestors first
50
+ ancestors = sorted(
51
+ ancestors,
52
+ key=lambda x: nx.shortest_path_length(RECIPE_GRAPH, source=x, target=target),
53
+ )
54
+
55
+ time_now = time.time()
56
+
57
+ def dfs(starting_inventory, steps, best_steps):
58
+ # If we have exceeded the timeout, return the best known path so far.
59
+ if time.time() - time_now > timeout:
60
+ raise TimeoutError("Timeout exceeded")
61
+
62
+ memo_key = (frozenset(starting_inventory.items()), len(steps))
63
+ if memo_key in memo:
64
+ return memo[memo_key]
65
+
66
+ if best_steps is not None and len(steps) >= len(best_steps):
67
+ # If we already have a shorter or equally short solution, do not proceed further.
68
+ return best_steps
69
+
70
+ if len(steps) > max_steps:
71
+ # If we have already exceeded the maximum number of steps, do not proceed further.
72
+ return best_steps
73
+
74
+ if target in starting_inventory and starting_inventory[target] > 0:
75
+ # If the target item is already in the inventory in the required amount, return the current path.
76
+ if best_steps is None or len(steps) < len(best_steps):
77
+ return steps
78
+ return best_steps
79
+
80
+ for recipe_name in [target] + ancestors:
81
+ # skip if already have 9 of the item
82
+ if starting_inventory.get(recipe_name, 0) >= 9:
83
+ continue
84
+ # TODO prevent looping between equivalent recipes (coal <-> coal_block)
85
+ for recipe in RECIPES[recipe_name]:
86
+ if recipe.can_craft_from_inventory(starting_inventory):
87
+ # Craft this item and update the inventory.
88
+ new_inventory = recipe.craft_from_inventory(starting_inventory)
89
+ # Add this step to the path.
90
+ new_steps = steps + [(recipe, new_inventory)]
91
+
92
+ # Recursively try to craft the target item with the updated inventory.
93
+ candidate_steps = dfs(new_inventory, new_steps, best_steps)
94
+
95
+ # Update the best known path if the candidate path is better.
96
+ if candidate_steps is not None and (
97
+ best_steps is None or len(candidate_steps) < len(best_steps)
98
+ ):
99
+ best_steps = candidate_steps
100
+
101
+ memo[memo_key] = best_steps
102
+ return best_steps
103
+
104
+ try:
105
+ path = dfs(inventory, steps, best_steps)
106
+ return path
107
+
108
+ except TimeoutError:
109
+ return None