class WorkPlan {
constructor(
items,
craftingPlan = new CraftingPlan({}),
target = new WorkTarget()
) {
this.items = items;
this.craftingPlan = craftingPlan;
this.target = target;
}
toJSON() {
return {
results: this.target,
crafting: this.craftingPlan
};
}
static fromJSON(items, json) {
return new WorkPlan(
items,
CraftingPlan.fromJSON(items, json.crafting),
WorkTarget.fromJSON(items, json.results)
);
}
get urlParam() {
if (this.target.items.length == 0) {
return "";
}
var namespaces = {};
// Start with wanted items and add recursively add recipes from the crafting plan. This ignores superfluous recipes in the crafting plan, which are nice to have in the UI but don't need to be persisted in the URL
var items = {};
var tools = {};
var pending = [
...this.target.items.map(result => ({
item: result.item,
quantity: result.quantity
}))
];
while (pending.length > 0) {
var { item, quantity } = pending.shift();
var { recipe, stock } = this.craftingPlan.items[item.name]
? this.craftingPlan.items[item.name]
: { recipe: item.recipes[0], stock: 0 };
var tool = recipe.tool.item ? recipe.tool.item.name : recipe.tool.name;
var quantity = items[item.name] ? items[item.name].quantity : quantity;
items[item.name] = {
item: item.name,
quantity: quantity,
tool: tool,
ingredients: recipe.ingredients.map(ing => ing.item.name),
stock: stock
};
if (tool) {
tools[tool] = true;
}
pending = [
...pending,
...recipe.ingredients.map(ing => ({ item: ing.item, quantity: 0 }))
];
}
var itemNames = [...Object.keys(items), ...Object.keys(tools)];
var namespaceTable = itemNames
.map(name => name.split(':', 1)[0])
.filter((elt, idx, arr) => arr.indexOf(elt) === idx)
.sort();
var itemNameRewrite = Object.fromEntries(
itemNames.map(function(name) {
var [namespace, item] = name.split(':', 2);
namespace = namespaceTable.indexOf(namespace).toString();
return [name, `${namespace}.${item}`];
})
);
var itemNameTable = Object.values(itemNameRewrite).sort();
function itemNameIndex(name) {
return itemNameTable.indexOf(itemNameRewrite[name]);
}
items = Object.values(items)
.map(({ item, quantity, stock, tool, ingredients }) =>
[
itemNameIndex(item).toString(),
quantity > 0 ? quantity.toString() : '',
stock > 0 ? stock.toString() : '',
tool ? itemNameIndex(tool).toString() : '',
...ingredients.map(ing => itemNameIndex(ing).toString())
].join('.')
)
.join('*');
itemNames = itemNameTable.join('*');
namespaces = namespaceTable.join('*');
return [items, itemNames, namespaces].join('**');
}
static fromURLParam(allItems, param) {
var [items, names, namespaces] = param
.split('**', 3)
.map(elt => elt.split('*'));
// First build a name table
var nameTable = Object.fromEntries(
names.map(function(name, idx) {
var [namespace, item] = name.split('.', 2);
namespace = namespaces[parseInt(namespace)];
return [idx.toString(), `${namespace}:${item}`];
})
);
// Then parse items
var work = items
.map(function(item) {
var [nameIdx, quantity, stock, toolIdx, ...ingredients] = item.split(
'.'
);
return {
itemName: nameTable[nameIdx],
quantity: quantity.length > 0 ? parseInt(quantity) : 0,
stock: stock.length > 0 ? parseInt(stock) : 0,
toolName: nameTable[toolIdx],
ingredientNames: ingredients.map(ing => nameTable[ing])
};
})
.map(({ itemName, quantity, stock, toolName, ingredientNames }) => {
var item = allItems[itemName];
// Find the recipe
var recipe = item.recipes.find(
recipe =>
deepEqual(
recipe.ingredients.map(ing => ing.item.name).sort(),
ingredientNames.sort()
) &&
(recipe.tool.item
? recipe.tool.item.name == toolName
: recipe.tool.name
? recipe.tool.name == toolName
: !toolName)
);
return { item: item, quantity: quantity, stock: stock, recipe: recipe };
});
return new WorkPlan(
allItems,
new CraftingPlan(
Object.fromEntries(
work.map(({ item, recipe, stock }) => [
item.name,
new CraftingPlanItem(recipe, stock)
])
)
),
new WorkTarget(
...work
.filter(({ quantity }) => quantity > 0)
.map(({ quantity, item }) => new WorkTargetItem(item, quantity))
)
);
}
// Given current crafting results and plan, returns viable recipe list for all items involved
// Excluded are:
// * All "use stock" placeholder recipes
// * All "gather" recipes for items in crafting results
// * Recipes that would create a cycle
get craftableRecipes() {
var isResult = Object.fromEntries(
this.target.items.map(work => [work.item.name, work.quantity > 0])
);
var dependencies = Object.fromEntries(
this.target.items.map(work => [work.item.name, []])
);
// Start from the current target results
var pending = [...this.target.items.map(work => work.item)];
var recipes = {};
while (pending.length > 0) {
var item = pending.shift();
// Craftable recipes for an item are all other than "use stock" and (for final results only) "gather"
var craftable = item.recipes.filter(
recipe =>
!(
recipe.tool.name == "_:stock" ||
// Disabled this because it doesn't account for items that are in final results and in intermdiates
// (recipe.tool.name == "_:gather" && isResult[item.name]) ||
// Cycle check -- exclude recipes whose ingredients depend on something that depends on item
recipe.ingredients.some(ing => {
var ingDeps = dependencies[ing.item.name] ?? [];
return ingDeps.includes(item);
})
)
);
// But don't create an empty list -- if all else fails, gather is always an option
if (craftable.length == 0) {
craftable = [
item.recipes.find(recipe => recipe.tool.name == "_:gather")
];
}
recipes[item.name] = craftable;
// If a recipe has been selected for an item, its ingredients should also be evaluated
// If there is only one remaining recipe for an item, it is implicitly selected
// If the selected recipe is not craftable, ignore it
var selectedRecipe =
craftable.length == 1
? craftable[0]
: this.craftingPlan.items[item.name]
? this.craftingPlan.items[item.name].recipe
: null;
selectedRecipe = craftable.includes(selectedRecipe)
? selectedRecipe
: null;
if (selectedRecipe) {
var ingredients = selectedRecipe.ingredients.map(ing => ing.item);
pending = [...pending, ...ingredients];
// Update dependency list. If a recipe has been selected for Y, then
// * all ingredients of the recipe are dependencies of Y
// * all ingredenties of the recipe are dependencies of anything that depends on Y
dependencies = Object.fromEntries(
Object.entries({
// Make sure item is in dependency table
...Object.fromEntries([[item.name, []]]),
...dependencies
}).map(([name, deps]) => [
name,
// Append recipe ingredients to item and all its dependents
deps.includes(item) || item.name == name
? [...deps, ...ingredients].filter(
// Only care about distinct deps
(val, idx, arr) => arr.indexOf(val) === idx
)
: deps
])
);
}
}
return recipes;
}
execute() {
var initialStock = Object.fromEntries(
Object.entries(this.craftingPlan.items).map(([name, planItem]) => [
name,
planItem.stock
])
);
var intermediates = {};
var steps = [];
var itemSteps = {};
var pending = [...this.target.items];
while (pending.length > 0) {
var work = { ...pending.shift() };
if (work.item.recipes.length > 0) {
// First, check if we have leftovers from earlier in the process
// Using leftovers doesn't get recorded as its own step
if (intermediates.hasOwnProperty(work.item.name)) {
var stockToUse = Math.min(
work.quantity,
intermediates[work.item.name]
);
work.quantity -= stockToUse;
intermediates[work.item.name] -= stockToUse;
} else {
intermediates[work.item.name] = 0;
}
// If we don't need more, we're done with this step
if (work.quantity == 0) {
continue;
}
// Next, check if we have the item in stock. Using a stock item does
// get recorded as its own (gather) step
var recipe = null;
var quantityToMake = 0;
if (
initialStock.hasOwnProperty(work.item.name) &&
initialStock[work.item.name] > 0
) {
var stockToUse = Math.min(
work.quantity,
initialStock[work.item.name]
);
initialStock[work.item.name] -= stockToUse;
recipe = work.item.recipes.find(
recipe => recipe.tool.name == '_:stock'
);
quantityToMake = stockToUse;
} else {
recipe = this.craftingPlan.items[work.item.name]
? this.craftingPlan.items[work.item.name].recipe
: work.item.recipes[0];
quantityToMake =
Math.ceil(work.quantity / recipe.quantity) * recipe.quantity;
}
var leftover = Math.max(quantityToMake - work.quantity, 0);
work.quantity = Math.max(work.quantity - quantityToMake, 0);
intermediates[work.item.name] += leftover;
// If this isn't the first time we need to craft this item,
// go back and make as much as we needed in the first place
var itemStepKey = `${work.item.name} - ${recipe.tool.name}`;
if (itemSteps[itemStepKey]) {
itemSteps[itemStepKey].quantity += quantityToMake;
} else {
var step = new WorkStep(recipe, quantityToMake, work.item);
steps.push(step);
itemSteps[itemStepKey] = step;
}
// If we still need to make more (for example, because we used
// stock, but didn't have enough), then put the item back in pending
if (work.quantity > 0) {
pending.unshift(work);
}
pending = [
...recipe.ingredients.map(
ing =>
new WorkItem(
ing.item,
Math.ceil(quantityToMake / recipe.quantity) * ing.quantity,
recipe
)
),
...pending
];
}
}
// Leftovers = unused initial stock + crafted and unused
var leftovers = Object.keys({ ...initialStock, ...intermediates })
.map(name => [
name,
(initialStock[name] ?? 0) + (intermediates[name] ?? 0)
])
.map(([name, quantity]) => ({
item: this.items[name],
quantity: quantity
}))
.filter(({ quantity }) => quantity > 0)
.filter(({ item }) => item.name != "_:fuel");
// Steps are listed in prefix dependency order (recipe result before recipe ingredients) -- this is ensured by the code above
// Crafting steps need to be listed in postfix dependency order (recipe result after ingredients)
// Because the dependencies could be shared between various components, we can't just reverse the order
// Also drop gather/substitute/stock steps from crafting steps
var craft = [];
var craftItems = [];
pending = [...steps].reverse();
while (pending.length > 0) {
// Find first item with no unmet dependencies and move it to craft list
var next = pending.find(work =>
work.recipe.ingredients.every(ing => craftItems.includes(ing.item))
);
craft.push(next);
craftItems.push(next.recipe.result);
pending = pending.filter(item => item !== next);
}
// Furthermore, we avoid having meta-items appear in the ingredient list of crafting recipes by substituting ingredients
var substitutions = Object.fromEntries(
steps
.filter(work => work.recipe.tool.name == "_:substitute")
.map(work => [work.recipe.result.name, work.recipe.ingredients[0].item])
);
craft = craft
.filter(
step =>
!["_:gather", "_:substitute", "_:stock"].includes(
step.recipe.tool.name
)
)
.map(work => {
var recipe = new Recipe(
work.recipe.quantity,
work.recipe.nonFuelIngredients.map(ing =>
substitutions[ing.item.name]
? new Ingredient(substitutions[ing.item.name], ing.quantity)
: ing
),
work.recipe.tool,
!work.recipe.fuel
? work.recipe.fuel
: substitutions[work.recipe.fuel.item.name]
? new Ingredient(
substitutions[work.recipe.fuel.item.name],
work.recipe.fuel.quantity
)
: work.recipe.fuel
);
recipe.result = work.item;
return new WorkItem(work.item, work.quantity, recipe);
});
return {
steps: steps,
stock: steps.filter(step => step.recipe.tool.name == "_:stock"),
gather: steps.filter(step => step.recipe.tool.name == "_:gather"),
craft: craft,
leftovers: leftovers
};
}
}