Created
August 5, 2019 09:03
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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