plancraft 0.1.0__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,215 @@
1
+ import logging
2
+ from typing import Optional
3
+
4
+ from plancraft.environments.actions import SymbolicAction
5
+ from plancraft.environments.recipes import (
6
+ RECIPES,
7
+ ShapedRecipe,
8
+ ShapelessRecipe,
9
+ SmeltingRecipe,
10
+ convert_ingredients_to_table,
11
+ )
12
+ from plancraft.environments.sampler import MAX_STACK_SIZE
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class PseudoActionSpace:
18
+ def no_op(self):
19
+ return {
20
+ "inventory_command": (0, 0, 0),
21
+ }
22
+
23
+
24
+ class SymbolicPlancraft:
25
+ def __init__(self, inventory: list[dict] = [], recipes=RECIPES, **kwargs):
26
+ self.inventory = inventory
27
+ self.reset_state()
28
+ self.table_indexes = list(range(1, 10))
29
+ self.output_index = 0
30
+
31
+ self.action_space = PseudoActionSpace()
32
+
33
+ self.recipes = recipes
34
+
35
+ self.smelting_recipes = []
36
+
37
+ self.crafting_recipes = []
38
+
39
+ for recipe_list in recipes.values():
40
+ for recipe in recipe_list:
41
+ if isinstance(recipe, SmeltingRecipe):
42
+ self.smelting_recipes.append(recipe)
43
+ elif isinstance(recipe, (ShapelessRecipe, ShapedRecipe)):
44
+ self.crafting_recipes.append(recipe)
45
+
46
+ def reset_state(self):
47
+ self.state = {i: {"type": "air", "quantity": 0} for i in range(46)}
48
+ # initialise inventory
49
+ for item in self.inventory:
50
+ self.state[item["slot"]] = {
51
+ "type": item["type"],
52
+ "quantity": item["quantity"],
53
+ }
54
+
55
+ def step(self, action: Optional[SymbolicAction | dict]):
56
+ if action is None:
57
+ state_list = [
58
+ {"type": item["type"], "quantity": item["quantity"], "index": idx}
59
+ for idx, item in self.state.items()
60
+ ]
61
+ return {"inventory": state_list}, 0, False, {}
62
+
63
+ # action_dict = action.to_action_dict()
64
+ if not isinstance(action, dict):
65
+ action = action.to_action_dict()
66
+
67
+ if "inventory_command" in action:
68
+ # do inventory command (move)
69
+ slot, slot_to, quantity = action["inventory_command"]
70
+ self.move_item(slot, slot_to, quantity)
71
+ elif "smelt" in action:
72
+ # do smelt
73
+ slot, slot_to, quantity = action["smelt"]
74
+ self.smelt_item(slot, slot_to, quantity)
75
+ else:
76
+ raise ValueError("Invalid action")
77
+ # logger.warn("Cannot parse action for Symbolic action")
78
+
79
+ self.clean_state()
80
+
81
+ # convert to list for same format as minerl
82
+ state_list = [
83
+ {"type": item["type"], "quantity": item["quantity"], "index": idx}
84
+ for idx, item in self.state.items()
85
+ ]
86
+
87
+ return {"inventory": state_list}, 0, False, {}
88
+
89
+ def clean_state(self):
90
+ # reset slot type if quantity is 0
91
+ for i in self.state.keys():
92
+ if self.state[i]["quantity"] == 0:
93
+ self.state[i]["type"] = "air"
94
+
95
+ def move_item(self, slot_from: int, slot_to: int, quantity: int):
96
+ if slot_from == slot_to or quantity < 1 or slot_to == 0:
97
+ return
98
+ # slot outside of inventory
99
+ if slot_from not in self.state or slot_to not in self.state:
100
+ return
101
+ # not enough
102
+ if self.state[slot_from]["quantity"] < quantity:
103
+ return
104
+
105
+ item = self.state[slot_from]
106
+
107
+ # slot to is not empty or is the same type as item
108
+ if (self.state[slot_to]["type"] == "air") or (
109
+ self.state[slot_to]["quantity"] <= 0
110
+ ):
111
+ self.state[slot_to] = {"type": item["type"], "quantity": quantity}
112
+ self.state[slot_from]["quantity"] -= quantity
113
+ elif self.state[slot_to]["type"] == item["type"] and (
114
+ MAX_STACK_SIZE[item["type"]] >= self.state[slot_to]["quantity"] + quantity
115
+ ):
116
+ # check if the quantity exceeds the max stack size
117
+ self.state[slot_to]["quantity"] += quantity
118
+ self.state[slot_from]["quantity"] -= quantity
119
+ else:
120
+ return
121
+
122
+ # reset slot if quantity is 0
123
+ if self.state[slot_from]["quantity"] == 0:
124
+ self.state[slot_from] = {"type": "air", "quantity": 0}
125
+
126
+ # use up ingredients
127
+ if slot_from == 0:
128
+ self.use_ingredients()
129
+
130
+ # populate craft slot if ingredients in crafting table have changed
131
+ if slot_to < 10 or slot_from < 10:
132
+ self.populate_craft_slot_craft_item()
133
+
134
+ def smelt_item(self, slot_from: int, slot_to: int, quantity: int):
135
+ if quantity < 1 or slot_to == 0 or slot_from == slot_to or slot_from == 0:
136
+ return # skip if quantity is less than 1
137
+
138
+ if slot_from not in self.state or slot_to not in self.state:
139
+ return # handle slot out of bounds or invalid slot numbers
140
+
141
+ item = self.state[slot_from]
142
+ if item["quantity"] < quantity or item["type"] == "air":
143
+ return # skip if the slot from is empty or does not have enough items
144
+
145
+ for recipe in self.smelting_recipes:
146
+ if output := recipe.smelt(item["type"]):
147
+ output_type = output.item
148
+ # Check if the destination slot is empty or has the same type of item as the output
149
+ if self.state[slot_to]["type"] == "air":
150
+ self.state[slot_to] = {"type": output_type, "quantity": quantity}
151
+ self.state[slot_from]["quantity"] -= quantity
152
+ break
153
+ elif self.state[slot_to]["type"] == output_type and (
154
+ MAX_STACK_SIZE[output_type]
155
+ >= self.state[slot_to]["quantity"] + quantity
156
+ ): # assuming max stack size is 64
157
+ self.state[slot_to]["quantity"] += quantity
158
+ self.state[slot_from]["quantity"] -= quantity
159
+ break
160
+ else:
161
+ return # No space or type mismatch in slot_to
162
+
163
+ # Clean up if the source slot is depleted
164
+ if self.state[slot_from] == 0:
165
+ self.state[slot_from] = {"type": "air", "quantity": 0}
166
+
167
+ if slot_to < 10 or slot_from < 10:
168
+ self.populate_craft_slot_craft_item()
169
+
170
+ def populate_craft_slot_craft_item(self):
171
+ # get ingredients from crafting table
172
+ ingredients = []
173
+ for i in self.table_indexes:
174
+ if self.state[i]["type"] != "air" and self.state[i]["quantity"] > 0:
175
+ ingredients.append(self.state[i]["type"])
176
+ else:
177
+ ingredients.append(None)
178
+ table = convert_ingredients_to_table(ingredients)
179
+
180
+ # check if any of the crafting recipes match the ingredients
181
+ for recipe in self.crafting_recipes:
182
+ if result := recipe.craft(table):
183
+ result, indexes = result
184
+ self.ingredients_idxs = indexes
185
+ self.state[self.output_index] = {
186
+ "type": result.item,
187
+ "quantity": result.count,
188
+ }
189
+ return
190
+
191
+ self.ingredients_idxs = []
192
+ self.state[self.output_index] = {"type": "air", "quantity": 0}
193
+
194
+ def use_ingredients(self):
195
+ # remove used ingredients from crafting table
196
+ for idx in self.ingredients_idxs:
197
+ self.state[idx + 1]["quantity"] -= 1
198
+ if self.state[idx + 1]["quantity"] <= 0:
199
+ self.state[idx + 1] = {"type": "air", "quantity": 0}
200
+ self.ingredients_idxs = []
201
+
202
+ def reset(self):
203
+ self.reset_state()
204
+ return self.state
205
+
206
+ def fast_reset(self, new_inventory: list[dict]):
207
+ self.inventory = new_inventory
208
+ self.reset_state()
209
+ return self.state
210
+
211
+ def render(self):
212
+ print(f"state: {self.state}")
213
+
214
+ def close(self):
215
+ self.reset_state()
environments/items.py ADDED
@@ -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