Published
Edited
Nov 25, 2020
1 fork
Importers
Insert cell
Insert cell
class Item {
constructor(name, recipes, icon) {
this.name = name;
this.recipes = [];
this.displayName = name
? name.replace(/^.*:/, "").replaceAll("_", " ")
: null;
this.cssClass = this.name.replace(":", "_");
this.iconData = icon;
this.image = this.iconData
? html`<img class='icon' src='${this.iconData}' alt=${this.displayName}/>`
: ``;
this.add_recipes(...recipes);
}

get html() {
return html`<span class='item'>${this.image}${this.displayName}</span>`;
}

add_recipes(...recipes) {
this.recipes = [...this.recipes, ...recipes];
recipes.forEach(recipe => {
recipe.result = this;
});
}

describe() {
return this.displayName;
}
}
Insert cell
class Recipe {
constructor(quantity, ingredients, tool, fuel) {
this.quantity = quantity;
this.ingredients = fuel
? [...ingredients.filter(ing => ing !== fuel), fuel]
: ingredients;
this.tool = tool;
this.fuel = fuel;
}

get nonFuelIngredients() {
return this.fuel
? this.ingredients.filter(ing => ing !== this.fuel)
: this.ingredients;
}

toJSON() {
return [
this.result.name,
this.tool.item ? this.tool.item.name : null,
...this.ingredients.map(ing => ing.item.name)
];
}

describe() {
if (this.tool.name == "_:gather") {
return html`${this.tool.action}`;
} else {
var ingredients = this.ingredients.filter(ing => ing !== this.fuel);
return html`${this.tool.action} ${ingredients.map(
(ing, idx) =>
html`<span>${ing.item.displayName}${
idx < ingredients.length - 1 ? `, ` : ``
}</span>`
)}`;
}
}

get id() {
return `${this.result.name}=${this.tool.item ? this.tool.item.name : ''}:${
this.ingredients
? this.ingredients.map(ing => ing.item.name).join("+")
: ""
}`;
}
}
Insert cell
class Ingredient {
constructor(item, quantity) {
this.item = item;
this.quantity = quantity;
}
}
Insert cell
class Tool {
constructor(action, item, name) {
this.action = action;
this.item = item;
this.name = name;
}
}
Insert cell
class WorkTarget {
constructor(...items) {
this.items = items;
}

eq(other) {
return this && other && deepEqual(this.toJSON(), other.toJSON());
}

toJSON() {
return Object.fromEntries(
this.items.map(work => {
var { item, quantity } = work.toJSON();
return [item, quantity];
})
);
}

static fromJSON(items, json) {
return new WorkTarget(
...Object.entries(json).map(
([name, quantity]) => new WorkTargetItem(items[name], quantity)
)
);
}
}
Insert cell
class CraftingPlan {
constructor(items) {
this.items = items;
}

toJSON() {
return this.items;
}

static fromJSON(items, json) {
return new CraftingPlan(
Object.fromEntries(
Object.entries(json)
.map(([name, elt]) => [name, CraftingPlanItem.fromJSON(items, elt)])
.filter(([name, item]) => item)
)
);
}
}
Insert cell
class CraftingPlanItem {
constructor(recipe, stock = 0) {
this.recipe = recipe;
this.stock = stock;
}

toJSON() {
return {
recipe: this.recipe.toJSON(),
stock: this.stock
};
}

static fromJSON(items, { recipe, stock }) {
var item = items[recipe[0]];
var recipe = item
? item.recipes.find(r => deepEqual(r.toJSON(), recipe))
: null;
return recipe ? new CraftingPlanItem(recipe, stock) : null;
}
}
Insert cell
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() {
// Generate a compact string representation of the workplan, for inclusion in URLs
// The format is
// rep = <item>,<item>,…|<name>,<name>,…|<namespace>,<namespace>,…
// item = <item name index>.<count>.<stock>.<tool name index>.<ingredient name inded>.<ingredient name inded>.…
// name = <namespace index>.<string>
// namespace = <string>
//
// Where name/tool/ingredient name index is a 0-based index into the name array in rep, and namespace index is a 0-based index into the namespace array in rep.
// 0 counts are encoded as empty strings
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
};
}
}
Insert cell
class WorkItem {
constructor(item, quantity, recipe) {
this.item = item;
this.quantity = quantity;
this.recipe = recipe;
}
}
Insert cell
class WorkTargetItem {
constructor(item, quantity) {
this.item = item;
this.quantity = quantity;
}
toJSON() {
return {
item: this.item.name,
quantity: this.quantity,
}
}
}
Insert cell
class WorkStep {
constructor(recipe, quantity, item) {
this.recipe = recipe;
this.quantity = quantity;
this.item = item;
}
describe() {
var result = html`${ this.recipe.tool.action } ${ this.quantity } ${ this.item.describe() }`;
var ingredients = this.recipe.ingredients.map(ing => html`<li>${ ing.quantity * this.quantity / this.recipe.quantity } ${ ing.item.describe() }</li>`);

if (this.recipe.tool.item && ingredients) {
return html`<li>use ${ this.recipe.tool.item.describe() } to ${ result } from: <ul>${ ingredients }</ul></li>`
} else if (this.recipe.tool.item) {
return html`<li>use ${ this.recipe.tool.item.describe() } to ${ result }</li>`
} else if (ingredients.length > 0) {
return html`<li>${ result } from: <ul>${ ingredients }</ul></li>`
} else {
return html`<li>${ result }</li>`
}
}
}


Insert cell
Insert cell
Insert cell
testWorkPlan.execute()
Insert cell
Insert cell
Insert cell
WorkPlan.fromURLParam(minecraftItems, testWorkPlan.urlParam)
Insert cell
Insert cell
Insert cell
Insert cell
import { minecraftItems } from "@airbornemint/mrc-data"
Insert cell
Insert cell
deepEqual = require('https://bundle.run/deep-equal@2.0.4')
Insert cell

One platform to build and deploy the best data apps

Experiment and prototype by building visualizations in live JavaScript notebooks. Collaborate with your team and decide which concepts to build out.
Use Observable Framework to build data apps locally. Use data loaders to build in any language or library, including Python, SQL, and R.
Seamlessly deploy to Observable. Test before you ship, use automatic deploy-on-commit, and ensure your projects are always up-to-date.
Learn more