Skip to content

Instantly share code, notes, and snippets.

@Pablissimo
Created August 5, 2019 09:03
Show Gist options
  • Save Pablissimo/dcbcabc744571fc432b02cc9c1f18924 to your computer and use it in GitHub Desktop.
Save Pablissimo/dcbcabc744571fc432b02cc9c1f18924 to your computer and use it in GitHub Desktop.
A quick command-line Node app to look at the Neo4j Minecraft database and figure out how to make any item in it
// We take a dependency on the neo4j-driver module for the connection to the database
const neo4j = require('neo4j-driver').v1;
// And we'll need a driver to connect to the database. We'll disable 'lossless integers'
// because sodding about with the Neo4j integer type to do node lookups is a pain, and
// our graph doesn't have anywhere near enough nodes to worry about blowing through 53
// bits of integer precision
const driver = neo4j.driver
(
'bolt://localhost:7687', // Default setup for Neo4j Desktop
neo4j.auth.basic("neo4j", "password"), // Daft password
{ disableLosslessIntegers: true } // See note above re: precision
);
// Create a session for our query to run in
const session = driver.session();
// We want the user to supply the name of the item we're looking for
// as an argument. To save them having to double-quote anything, we'll
// assume all arguments to the program are to be concatenated with spaces.
let itemName;
if (process.argv.length > 2) {
itemName = process.argv.slice(2).join(" ").trim();
}
// If the user didn't give us an item to look up, give them some usage
// feedback and exit
if (!itemName) {
console.log("Usage: node craft.js item-to-lookup-here");
console.log("Example: node craft.js Wood Axe");
process.exit();
}
// Our query will walk the tree from the target resource to its required raw materials
// via :PRODUCES and :REQUIRES relationship pairs. We'll end up with tuples of
// {StartNode, Relationship, EndNode}
const queryTemplate = 'MATCH (r: Resource { name: $name }) \
CALL apoc.path.subgraphAll(r, {relationshipFilter: \'<PRODUCES,REQUIRES>\', maxLevel: 10}) YIELD relationships \
UNWIND relationships as rel \
RETURN startNode(rel) as StartNode, rel as Relationship, endNode(rel) as EndNode';
// We'll want to have a neater representation of a node in the graph than what
// the driver supplies. We'll have three main properties:
// * type (the label of the node, so Recipe or Resource)
// * name (the value of the 'name' property on the node)
// * identity (Neo4j's node ID, so we can use it as a key for lookups)
const toNode = (neoNode) => {
var toReturn = {
type: neoNode.labels[0],
name: neoNode.properties.name,
identity: neoNode.identity
};
// If it's a Recipe node, we will also initialise two arrays for future use
// * inputs - that will list the Resource nodes that combine to make this Recipe
// * output - the node this Recipe produces
if (toReturn.type == "Recipe") {
toReturn.inputs = [];
toReturn.output = null;
}
return toReturn;
};
// We'll use a Javascript object as an associative array keyed by the
// node's identity property, so that we can quickly look up a node by its
// ID without having to iterate through the whole array
const allNodesLUT = {};
// We'll also want a representation of a relationship between two nodes. Neo will
// give us IDs, but so long as we've had our allNodesLUT entries added we can
// also look up the matching node representation and tack that in to make
// traversing the tree cleaner
const toRelationship = (neoRelationship) => {
var toReturn = {
type: neoRelationship.type,
endId: neoRelationship.end,
startId: neoRelationship.start,
endId: neoRelationship.end
};
// Blindly copy properties into the relationship so that
// they're easy to access
Object.assign(toReturn, neoRelationship.properties);
// Look up the start and end nodes and assign them
toReturn.startNode = allNodesLUT[toReturn.startId];
toReturn.endNode = allNodesLUT[toReturn.endId];
return toReturn;
};
// We'll also maintain a lookup list of Recipe-type nodes keyed by the
// resource name. To support case-insensitivity, we'll upper-case the
// resource names when using them as keys
const recipeNodes = {};
// When we have a node in our own representation, add it to the all-nodes
// lookup list. If it's a Recipe, also add it to the Recipe lookup keyed
// by its name in upper-case
const registerNode = node => {
// The same node may appear multiple times in the Neo4j output, but
// we only need one representation of it
if (allNodesLUT[node.identity]) {
return;
}
allNodesLUT[node.identity] = node;
if (node.type === "Recipe") {
recipeNodes[node.name.toUpperCase()] = node;
}
};
// Builds a plain JSON object with relationship data, but also augments the referenced
// start and end Recipe or Resource nodes so that we end up with a tree containing each
// recipe's input and output resources and quantities
const registerRelationship = (relationship) => {
// Both relationship types in our model start from the Recipe node.
// If the relationship is a 'PRODUCES' one, the end node represents the produced item
// or the 'output' of the recipe
if (relationship.type === "PRODUCES") {
relationship.startNode.output = { resource: relationship.endNode, qty: relationship.qty };
}
// If the relationship is a 'REQUIRES' one, then the end node represents one of the
// ingredients to the recipe, or one of its 'inputs'
else if (relationship.type === "REQUIRES") {
relationship.startNode.inputs.push({ resource: relationship.endNode, qty: relationship.qty });
}
};
// At some point we're going to want to output text to the user about both which materials are needed
// to produce the item they've requested and which Recipes caused that demand. Because we're walking a tree
// we'll need to push and pop indentation a lot, so let's define a quick logger class to help
class Logger {
// Keep track of our indent depth
constructor() {
this.indentDepth = 0;
}
// We'll expose a method for actually logging some text
log(text) {
// Add enough padding based on our log depth then output
// the string
const padding = " ".repeat(this.indentDepth);
console.log(`${padding}${text}`);
}
// We'll also expose push and pop methods to increment and decrement the indentation
pushIndent() {
this.indentDepth++;
}
popIndent() {
this.indentDepth--;
}
}
// Finally, let's have a global of that logger available to all the other code
const logger = new Logger();
// For any given Resource we'll need a way to look up the Recipe that
// describes how to make it. Some resources won't have Recipes - raw
// materials - so we'll be returning undefined in those cases
const findRecipeForResourceName = (resourceName) => {
return recipeNodes[resourceName.toUpperCase()];
};
// Our algorithm centres on the idea of a shopping list that keeps track
// of what we've made and what we need to retrieve from the environment
// so we'll need to represent that next.
class ShoppingList {
// Our getResource method will add line items for yet-unseen resource
// names as they come in, and initialise variables on those line items
getResource(resourceName) {
const resourceKey = resourceName.toUpperCase();
let toReturn = this[resourceKey];
if (!toReturn) {
toReturn = {
created: 0,
required: 0,
resource: resourceName
};
this[resourceKey] = toReturn;
}
return toReturn;
};
// We need a way to record that a Recipe has output some quantity
// of some resource, so our createResource method will
// just increment the 'created' count of a given line item
// by the requested amount
createResource(resourceName, qty) {
const resource = this.getResource(resourceName);
resource.created += qty;
logger.log(`Created ${qty} x ${resourceName} (${resource.created} created in total so far)`);
};
// We also want to be able to secure as many of a given resource
// as we can as an input to a recipe. This attempt might be
// unsuccessful (we haven't produced any of that resource yet), or
// may be partially successful (there's some of that resource going
// spare, but not enough to cover the demand).
allocateResource(resourceName, qty) {
// Our logging messages for this method all start with the same text
const logStringPrefix = `Looked in resource bag for ${resourceName} x ${qty}`;
const resource = this.getResource(resourceName);
// How many of the resource have been created but not yet allocated to
// some other recipe run?
const unallocated = resource.created - resource.required;
// If there are spare, unallocated units of the resource available then
// allocate as much of it as we can to this recipe's requirements. We might
// manage to allocate all of it, we might not - so we return to the caller
// the number of units of the resource we managed to allocate
if (unallocated > 0) {
// We can allocate at least some of our demand (maybe all). Log this, then
// return the number we managed to allocate
const allocation = Math.min(qty, unallocated);
resource.required += allocation;
logger.log(`${logStringPrefix} and found ${allocation} of them leaving ${unallocated - allocation} left`);
return allocation;
}
else {
// We didn't find any unallocated resource, so log that and return 0
logger.log(logStringPrefix + " but didn't find any");
return 0;
}
};
// We also need a way to demand a certain number of units of a
// Resource that might be an input to a recipe but that can't be
// produced by some other recipe
demandResource(resourceName, qty) {
const resource = this.getResource(resourceName);
resource.required += qty;
logger.log(`Created a demand for ${qty} x ${resourceName} (${resource.required} needed in total so far)`);
};
// Finally, we need to expose the contents of the shopping list to a caller for printing
// to the screen. We're really only interested in two cases:
// * Where there's a surplus of a resource - i.e. we over-produced it...
getSurplusResources() {
const toReturn = [];
// Iterate over all the resource names registered to this shopping list
for (let itemName in this) {
const thisItem = this.getResource(itemName);
// If we created more of the resource than recipes needed then we have a surplus
if (thisItem.created > thisItem.required) {
toReturn.push({ name: thisItem.resource, qty: thisItem.created - thisItem.required });
}
}
return toReturn;
};
// * ...or where there's a shortage of a resource - i.e. we need to pull it from the world
getDemandedResources() {
const toReturn = [];
// Iterate over all the resource names registered to this shopping list
for (let itemName in this) {
const thisItem = this.getResource(itemName);
// If we created more of the resource than recipes needed then we have a surplus
if (thisItem.required > thisItem.created) {
toReturn.push({ name: thisItem.resource, qty: thisItem.required - thisItem.created });
}
}
return toReturn;
};
};
// We now need a way to 'run' a Recipe - that is, to take its inputs and produce units of
// its output.
const runRecipe = (recipe, shoppingList) => {
// We'll log that we're running the Recipe so we can see the chain of events
// that led to the final output being produced
logger.log(`Running recipe ${recipe.name}`);
logger.pushIndent();
// Then for each input to our Recipe...
for (var i = 0; i < recipe.inputs.length; i++) {
// ...we process that input - either grabbing it from the shopping list or creating a
// demand for it or producing the input by running more recipes. We haven't defined this
// yet, we'll do so in a second
processRecipeInput(recipe.inputs[i], shoppingList);
}
// Once we're processed all our inputs, we can produce our output in the right
// quantity and we're done with this Recipe
shoppingList.createResource(recipe.output.resource.name, recipe.output.qty);
logger.popIndent();
}
// But what does processing a Recipe input look like?
// We've three cases to cover:
// * 1) There's no Recipe to make the input to this Recipe - we need to find the material
// in the environment, so we create a demand for it and move on
// * 2) There's enough of the input resource in the shopping list already - we just allocate
// it to ourselves and move on, there's nothing else to do
// * 3) There's not enough of the input resource in the shopping list, in which case
// we need to run the Recipe that produces this input enough times to produce
// the shortfall
const processRecipeInput = (input, shoppingList) => {
// First see if there's a Recipe for making this input
var inputResourceRecipe = findRecipeForResourceName(input.resource.name);
if (!inputResourceRecipe) {
// Case #1: There are no recipes that create this resource so we assume it's gathered
// or mined and is a raw material. Just flag that we need some and move on
shoppingList.demandResource(input.resource.name, input.qty);
}
else {
// The other two cases need us to at least try to allocate the quantity of the
// input resource from the shopping list. Who knows, we might have enough in there
// already to not need to do any work!
const allocated = shoppingList.allocateResource(input.resource.name, input.qty);
if (allocated === input.qty) {
// Case #2: We need input.qty units of the input resource, and the shopping list
// had them in stock - no more work to do!
}
else {
// Case #3: We allocate either none or some of our required amount of input, but
// there's still a shortfall.
// First let's figure out how much short we are:
const shortfall = input.qty - allocated;
// We'll need to run the Recipe for our input enough times to generate that many units
// but how many units does the Recipe produce per run?
const recipeOutputQty = inputResourceRecipe.output.qty;
// The number of Recipe runs is then a simple division, but rounded up
var recipeRunsRequired = Math.ceil((input.qty - allocated) / recipeOutputQty);
// Run the Recipe the required number of times. We pass in exactly the same shoppingList
// that we were given so that by the end of the runs we'll see the updated list and be able
// to allocate our remaining shortfall from it.
for (var i = 0; i < recipeRunsRequired; i++) {
runRecipe(inputResourceRecipe, shoppingList);
}
// We should now be able to allocate the remaining amount of resource - the shortfall...
const remainderAllocated = shoppingList.allocateResource(input.resource.name, shortfall);
// ...and if we can't then something's gone pretty horribly wrong
if (remainderAllocated < shortfall){
throw "We somehow failed to allocate resources we just produced?";
}
}
}
};
// We can finally start running queries. Since queries run asynchronously, we'll
// get a Promise back from the run() call.
const resultPromise = session.run(queryTemplate, { name: itemName });
// When the Promise resolves, we'll have a result object that contains our query
// results
resultPromise.then((result) => {
// Regardless of what the query did we're done with our query session so clean that
// up first, and the driver that spawned it
session.close();
driver.close();
// The Neo node representation needs to be converted to our internal representation using
// the helper functions we first defined
result.records.forEach(r => {
registerNode(toNode(r.get("StartNode")));
registerNode(toNode(r.get("EndNode")));
// For each recipe, add its requirements and note its output quantity
// and output type (just in case our recipe name differs from the item it
// produces)
registerRelationship(toRelationship(r.get("Relationship")));
});
// Find the Recipe for the resource we're starting with
const matchingRecipe = findRecipeForResourceName(itemName);
if (!matchingRecipe) {
logger.log(`Couldn't find recipe for ${itemName}`);
// Exit with a failure code
process.exit(1);
}
logger.log(`Found matching recipe for ${itemName}`);
// Now we just run the Recipe. runRecipe takes a Recipe to run
// and a ShoppingList instance to store its state, so build one then
// run the Recipe to completion.
const shoppingList = new ShoppingList();
runRecipe(matchingRecipe, shoppingList);
// Now let's generate some output - the shoppingList contains all the
// state we want to display.
const surpluses = shoppingList.getSurplusResources();
const deficits = shoppingList.getDemandedResources();
// Finally let's tell the user what they'd need to gather...
logger.log(`\n\nTo produce 1 x ${itemName} you will need the following raw materials:`);
for (let i = 0; i < deficits.length; i++) {
logger.log(`\t${deficits[i].qty} x ${deficits[i].name}`);
}
// ...and if there are any surplus resources then list them out too
if (surpluses.length) {
logger.log("\nYou'll be left with the following surplus resources:");
for (let i = 0; i < surpluses.length; i++) {
logger.log(`\t${surpluses[i].qty} x ${surpluses[i].name}`);
}
}
})
/*
.catch((ex) => {
console.log("Failed to run the query (is the database running?)");
console.log(ex);
});
*/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment