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

Sign up to get free protection for your applications and to get access to all the features.
@@ -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