1

My application references a database object that acts as a catalog. It's a catalog of items that can be crafted if the user has the necessary components. Here is a small sample of the catalog:

const itemCatalog = {
    "bramble_vest" : {
        "components" : [ "Chain Vest", "Chain Vest" ],
        "name" : "Bramble Vest"
    },
    "guardian_angel" : {
        "components" : [ "B.F. Sword", "Chain Vest" ],
        "name" : "Guardian Angel"
    },
    "hextech_gunblade" : {
        "components" : [ "B.F. Sword", "Needlessly Large Rod" ],
        "name" : "Hextech Gunblade"
    },
    "locket_of_the_iron_solari" : {
        "components" : [ "Chain Vest", "Needlessly Large Rod" ],
        "name" : "Locket of the Iron Solari"
    },
    "morellonomicon" : {
        "components" : [ "Giant's Belt", "Needlessly Large Rod" ],
        "name" : "Morellonomicon"
    },
    "sunfire_cape" : {
        "components" : [ "Chain Vest", "Giant's Belt" ],
        "name" : "Sunfire Cape"
    },
    "zekes_herald" : {
        "components" : [ "B.F. Sword", "Giant's Belt" ],
        "name" : "Zeke's Herald"
    }
}

When the user has the necessary components for any given item, the user can assemble that item. The user is awarded components arbitrarily and randomly, but how the user receives the components is not relevant to my question. Suffice to say that the user's components are put into an array on the client, which is then used to determine which items the user can assemble:

let myComponents = [
    "B.F. Sword",
    "Chain Vest",
    "Giant's Belt",
    "Chain Vest",
    "Needlessly Large Rod"
]

I have written a block of code that determines which items are possible with the elements in myComponents. That's fairly straightforward, even though it isn't particularly concise or stylish.

With the components listed in myComponents all of the items in this sample of itemCatalog are possible. However, they are not simultanesouly possible. The reason for this, of course, is that there are not enough components for all the items.

I need logic that can determine which items are simultaneously possible, given the components in myComponents when referenced against itemCatalog. The output should be an array of arrays. Each inner array would be a list of simultaneously possible catalog items. In this case, with the components currently in myComponents it would look like this:

[ 
    ["Bramble Vest", "Hextech Gunblade"], 
    ["Bramble Vest", "Morellonomicon"], 
    ["Bramble Vest", "Zeke's Herald"], 
    ["Guardian Angel", "Locket of the Iron Solari"], 
    ["Guardian Angel", "Morellonomicon"], 
    ["Guardian Angel", "Sunfire Cape"], 
    ["Hextech Gunblade", "Sunfire Cape"], 
    ["Locket of the Iron Solari", "Sunfire Cape"], 
    ["Locket of the Iron Solari","Zeke's Herald"]
]

Below is my current logic. There's a lot of logging there to help sift through, but the main issue with the function buildSimultaneousItems() is that once an item is checked against another item during iteration, those two items aren't checked again. I don't want to get into it too much, as I don't want to scare people away with information overload. It's all pretty straightforward, despite its ugliness. The main thing is that the expected output is above. Please feel free to ask questions.

// A catalog of items that can be assembled using components.
// The app uses this as a reference. This catalog is larger in the app, with many more items.
const itemCatalog = {
  "bramble_vest" : {
    "components" : [ "Chain Vest", "Chain Vest" ],
    "name" : "Bramble Vest"
  },
  "guardian_angel" : {
    "components" : [ "B.F. Sword", "Chain Vest" ],
    "name" : "Guardian Angel"
  },
  "hextech_gunblade" : {
    "components" : [ "B.F. Sword", "Needlessly Large Rod" ],
    "name" : "Hextech Gunblade"
  },
  "locket_of_the_iron_solari" : {
    "components" : [ "Chain Vest", "Needlessly Large Rod" ],
    "name" : "Locket of the Iron Solari"
  },
  "morellonomicon" : {
    "components" : [ "Giant's Belt", "Needlessly Large Rod" ],
    "name" : "Morellonomicon"
  },
  "sunfire_cape" : {
    "components" : [ "Chain Vest", "Giant's Belt" ],
    "name" : "Sunfire Cape"
  },
  "zekes_herald" : {
    "components" : [ "B.F. Sword", "Giant's Belt" ],
    "name" : "Zeke's Herald"
  }
}

// Components the user currently has
let myComponents = [
  "B.F. Sword",
  "Chain Vest",
  "Giant's Belt",
  "Chain Vest",
  "Needlessly Large Rod"
]

// Returns array of possible items with provided component combinations (myComponents)
getPossibleItems = (arr) => {
  let possibleItems = [];
  for (const possItem in arr) {
    if (doArraysMatch(arr[possItem].components, myComponents) ==  true) {
      possibleItems.push(arr[possItem].name);
    }
  }
  return possibleItems;
}

// Returns array of components at corresponding indices that correspond to the array returned in the above function
getPossItemsComponents = (arrA, arrB) => {
  let possItemsComponents = []
  for (const item in arrA) {
    for (const combItem in arrB) {
      console.log(arrB[combItem].name, ": ",arrB[combItem].components);
      if (arrA[item] == arrB[combItem].name) {
        possItemsComponents.push(arrB[combItem].components);
      }
    }
  }
  return possItemsComponents;
}

// Attempts to return an array of arrays. Each inner array is a list of items that can be
// assembled SIMULTANEOUSLY with the provided components (myComponents)
buildSimultaneousItems = () => {
  let terms = [];   
  possibleItems = getPossibleItems(itemCatalog);
  possibleItemsComponents = getPossItemsComponents(possibleItems, itemCatalog);
  for (let i = 0; i < possibleItems.length; i++) {
    let simultaneousItems = [];
    let simultaneousItemsComponents = [];
    simultaneousItems.push(possibleItems[i]);
    console.log(JSON.stringify(possibleItems[i]), ": ", JSON.stringify(possibleItemsComponents[i]), "-----------------------")
    simultaneousItemsComponents.push(possibleItemsComponents[i]);
    //console.log(possibleItemsComponents[i][0])
    for (let j = 0; j < possibleItems.length; j++) {
      console.log("Does myItems", JSON.stringify(myComponents), " contain ",JSON.stringify(simultaneousItemsComponents[0].concat(possibleItemsComponents[j])), " for ", JSON.stringify(possibleItems[j]),this.containsAllItems(myComponents, simultaneousItemsComponents[0].concat(possibleItemsComponents[j])))
      while (containsAllItems(myComponents, simultaneousItemsComponents[0].concat(possibleItemsComponents[j]))) {
        simultaneousItems.push(possibleItems[j]);
        console.log("Add ", JSON.stringify(possibleItemsComponents[j]), " to ", JSON.stringify(simultaneousItemsComponents[0]))
        simultaneousItemsComponents[0].push(possibleItemsComponents[j][0]);
        simultaneousItemsComponents[0].push(possibleItemsComponents[j][1]);
      }
    }
    terms.push(simultaneousItems);
  }
  console.log(terms)
}

// Utility functions for comparing arrays -------------------------- //

doArraysMatch = (subset, superset) => {
  const subsetCount = _.countBy(subset);
  const supersetCount = _.countBy(superset);
  return _.every(subsetCount, (count, value) => supersetCount[value] >= count);
}

containsAllItems = (arrA, arrB) => {
  arrA.forEach(elA => {
    if (arrB.includes(elA)) {
      arrB.splice(arrB.indexOf(elA), 1);
    }
  })
  if (arrB.length == 0) {
    return true;
  } else {
    return false;
  }
}

buildSimultaneousItems()
<script src="https://cdn.jsdelivr.net/npm/lodash@4.17.10/lodash.min.js"></script>
J. Adam Connor
  • 1,694
  • 3
  • 18
  • 35
  • are you wanting all possible combinations, and if so what is the desired output? – r3wt Dec 21 '20 at 22:33
  • Stop using lodash first. – Akihito KIRISAKI Dec 21 '20 at 22:33
  • @r3wt this isn't about possible combinations. `itemCatalog` gives us the possible combinations. I want to know which combinations are simultaneously possible given the user's components in `myComponents`. Imagine that each of those components are consumed upon creation of the item. The expected output with the given components is listed in the question. – J. Adam Connor Dec 21 '20 at 22:37
  • @AkihitoKIRISAKI can you elaborate on why? – J. Adam Connor Dec 21 '20 at 22:38
  • 3
    @J.AdamConnor it makes it difficult to read code for those who aren't familiar with lodash. it also usually leads to inefficient code. finally, `lodash` is a relic of its time when javascript lacked tons of features for arrays and objects. most of the problems it can solve can be done in pure js these days. – r3wt Dec 21 '20 at 22:40
  • You have already posted this question here: https://stackoverflow.com/questions/65319596/creating-an-array-of-unique-combinations – pilchard Dec 21 '20 at 22:40
  • @pilchard The way that question was phrased was too general and resulted in answers that had more to do with string manipulation than with data parsing. – J. Adam Connor Dec 21 '20 at 22:42
  • Actually I just tested my answer there on your data here and it works (though you haven't indicated how to handle duplicates in the list). – pilchard Dec 21 '20 at 22:45
  • @J.AdamConnor lodash is too huge to only control data structure. Lots of their methods are able to be realized standard browser API. – Akihito KIRISAKI Dec 21 '20 at 22:45
  • @pilchard are you referring to duplicates in the output or duplicates in `myComponents`? – J. Adam Connor Dec 21 '20 at 22:47
  • Does this answer your question? [Creating an Array of Unique Combinations](https://stackoverflow.com/questions/65319596/creating-an-array-of-unique-combinations) – pilchard Dec 21 '20 at 22:53
  • I raised a [related question](https://stackoverflow.com/q/65415963/1243641) about the algorithm. – Scott Sauyet Dec 22 '20 at 21:37

3 Answers3

2

(Note: there is an updated version below handling an additional requirement.)

Here's another approach, based on a simple recursive algorithm: We look at the first item in the list and if we can make it, we combine it with each of the results formed by calling the function with the remainder of the targets and the list of components less the ones required to make this item. If we can't make the first item, we just recur with the remainder of the items and the full list of components. The recursion bottoms out when the list of items is empty. To use this, we first convert your catalog to an array with Object.values, since we don't need your object keys at all.

Once we've found our collections, we remove those that are strict subsets of another. That is because as well as the full values you want, the collect function also gathers sets that could still contain others. With your above data, for instance, it collects [["Bramble Vest", "Hextech Gunblade"], ["Bramble Vest", "Morellonomicon"], ["Bramble Vest", "Zeke's Herald"], ["Bramble Vest"], ...] (with a dozen more items, many containing single components.) Note that the fourth item, ["Bramble Vest"], is a strict subset of each of the three earlier ones. Using maximize, we remove such subsets from the result.

This breakdown is useful because collect expresses a useful algorithm on its own. (The implementation is still tied to your structure, using the components and name properties of each item, but it would not be difficult to make more generic.) That algorithm takes items, a collection of collections of components, and components, a collection of components, and returns a list of all possible collections of items that could be made with that fixed list of components. Layering maximize atop this gives us both your goal and this somewhat more general algorithm together. It's also a simpler algorithm, as far as I can tell. Perhaps someone can show me a simplification that does these two steps in one.

Here's an implementation:

// utility functions
const dropFirst = (x, xs, i = xs .indexOf (x)) =>
  i < 0 ? [... xs] : [... xs .slice (0, i), ... xs .slice (i + 1)]

const dropEach = ([x, ...xs], ys) => 
  x == undefined ? ys : dropEach (xs, dropFirst (x, ys))

const canMake = ([c, ...cs], comps) => 
  c == undefined ? true : comps .includes (c) ? canMake (cs, dropFirst (c, comps)) : false

const isSubset = (xs, ys) =>
  xs .every (x => ys .includes (x))

const maximize = (xs) => 
  xs .filter (x => ! (xs .some (y => x !== y && isSubset (x, y))))


// main function
const collect = ([x, ...xs], ys) => 
  x == undefined
    ? [[]]
  : canMake (x.components, ys)
    ? [
        ... collect (xs, dropEach (x .components, ys)) .map (coll => [x .name, ... coll]), 
        ... collect (xs, ys)
      ]
    : collect (xs, ys)


// public function
const simultaneousItems = (catalog, components) => 
  maximize (collect (Object.values(catalog), components))


// sample data
const itemCatalog = { bramble_vest: {components : [ "Chain Vest", "Chain Vest" ], name : "Bramble Vest"}, guardian_angel: {components : [ "B.F. Sword", "Chain Vest" ], name : "Guardian Angel"}, hextech_gunblade: {components : [ "B.F. Sword", "Needlessly Large Rod" ], name : "Hextech Gunblade"}, locket_of_the_iron_solari: {components : [ "Chain Vest", "Needlessly Large Rod" ], name : "Locket of the Iron Solari"}, morellonomicon: {components : [ "Giant's Belt", "Needlessly Large Rod" ], name : "Morellonomicon"}, sunfire_cape: {components : [ "Chain Vest", "Giant's Belt" ], name : "Sunfire Cape"}, zekes_herald: {components : [ "B.F. Sword", "Giant's Belt" ], name : "Zeke's Herald"}}

const myComponents = ["B.F. Sword", "Chain Vest", "Giant's Belt", "Chain Vest", "Needlessly Large Rod"]


// demo
console .log (
  simultaneousItems(itemCatalog, myComponents)
)
.as-console-wrapper {max-height: 100% !important; top: 0}

We start with a collection of utility functions:

  • dropFirst removes the first occurrence of a value in an array of values. For instance,

    //                          v------------ First 'bar'
    dropFirst ('bar', ['foo', 'bar', 'baz', 'qux', 'bar', 'bar', 'corge']) 
    //=> ["foo", "baz", "qux", "bar", "bar", "corge"]
    //          ^---------------------------- now missing
    
  • dropEvery extends this to remove each of a list of values from the main list, using dropFirst. For instance

    //   will all be removed -----------v------v--------------------v              
    dropEach (['bar', 'foo', 'bar'], ['foo', 'bar', 'baz', 'qux', 'bar', 'bar', 'corge']) 
    //=> ["baz", "qux", "bar", "corge"]
    
  • canMake reports whether we can make a list of components given the components at hand. For instance, using your sample list of components,

    canMake (['B.F. Sword', 'Chain Vest']) (myComponents) //=> true
    canMake (['B.F. Sword', 'Chain Vest', 'B.F. Sword']) (myComponents) //=> false
    

    The first works because we have both the sword and the vest in our components. The second fails because we have only one sword.

    There are numerous other techniques we could use to write this function. The recursive version fit with the rest of these functions, but we could also have compared counts of the relevant strings between the item's components and our available components.

(Note: these first three functions might have been much easier if we implemented a MultiSet/Bag type for both the items' components and our overall list of components. I won't try that here, but it might be worth investigating.)

  • isSubset simply reports if one array of strings is a subset of another. Here we don't care about multiplicities, as our outputs don't include many copies of any one of our items.

  • maximize is discussed above. It removes from a list of collections those that are subsets of another in the list.

Then we have our central function,

  • collect, which determines which subsets of our list of items can be made with our components. The algorithm is described above.

And our public wrapper function,

  • simultaneousItems, which calls Object.values on your input to put it into a useful format for collect, passes that and the list of components to collect, and then calls maximize on the results. This function yields the input I think you want.

This is the output from the data supplied:

[
  ["Bramble Vest", "Hextech Gunblade"],
  ["Bramble Vest", "Morellonomicon"],
  ["Bramble Vest", "Zeke's Herald"],
  ["Guardian Angel", "Locket of the Iron Solari"],
  ["Guardian Angel", "Morellonomicon"],
  ["Guardian Angel", "Sunfire Cape"],
  ["Hextech Gunblade", "Sunfire Cape"],
  ["Locket of the Iron Solari", "Sunfire Cape"], 
  ["Locket of the Iron Solari", "Zeke's Herald"]
]

If we add a second "B.F. Sword" to our list of components, we get this list:

[
  ["Bramble Vest", "Hextech Gunblade", "Zeke's Herald"],
  ["Bramble Vest", "Morellonomicon"],
  ["Guardian Angel", "Hextech Gunblade", "Sunfire Cape"],
  ["Guardian Angel", "Locket of the Iron Solari", "Zeke's Herald"],
  ["Guardian Angel", "Morellonomicon"],
  ["Locket of the Iron Solari", "Sunfire Cape"]
]

It would be an interesting exercise to turn collect into a more generic function that was still easy to use to define makeSimultaneous. I would also not be surprised if that generic problem was a well-known problem with some optimized algorithms for it. I'd be curious about the algorithmic performance as well. But all that's for another day.


There is also a reasonable argument to be made for turning your output into a Set of Sets rather than an array of arrays. The ordering of the arrays is irrelevant, and in any such case, a Set is a more logical data structure. I probably wouldn't do this, as logical as it is, as I still find arrays easier to work with. But it's worth considering.

Update

A comment from the OP described an additional requirement not met by the above: The items we collect can occur multiple times. This might be clear to someone who knows the underlying game in question, but the code above doesn't handle it.

Moreover, it's not a simple fix. The design of collect above was to choose whether to gather the first item supplied (if possible) or not, and then recur on the remaining items and the components left after using up the necessary ones for the item. I saw no simple way to change that to allow multiple copies.

So here is a rewrite of collect with a mixture of existing helper functions and new ones to support it:

// utility functions
const dropFirst = (x, xs, i = xs .indexOf (x)) =>
  i < 0 ? [... xs] : [... xs .slice (0, i), ... xs .slice (i + 1)]

const dropEach = ([x, ...xs], ys) => 
  x == undefined ? ys : dropEach (xs, dropFirst (x, ys))

const dropEachRepeatedly = (n, xs, ys) =>
  n == 0 ? ys : dropEach(xs, dropEachRepeatedly(n - 1, xs, ys))

const canMake = ([c, ...cs], comps) => 
  c == undefined ? true : comps .includes (c) ? canMake (cs, dropFirst (c, comps)) : false

const howMany = (xs, ys) => 
  canMake (xs, ys)
    ? 1 + howMany (xs, dropEach(xs, ys))
    : 0

const range = (lo, hi) => Array .from ({length: hi - lo + 1}, (_, i) => i + lo)

const count = (xs) => 
  xs .reduce ((a, x) => ((a[x] = (a[x] || 0) + 1), a), {})

const isMultiSubset = (xs, ys, cx = count (xs), cy = count (ys)) =>
  Object .keys (cx) .every (x => cx [x] <= (cy [x] || 0))

const maximize = (xs) => 
  xs .filter (x => ! (xs .some (y => x !== y && isMultiSubset (x, y))))


// main function
const collect = ([x, ...xs], ys) => 
  x == undefined
    ? [[]]
    : range (0, howMany (x.components, ys)) .reverse() .flatMap(
        (n) => collect(xs, dropEachRepeatedly(n, x.components, ys)) .map (
          coll =>  [...Array(n).fill(x.name), ...coll]
        )
      )


// public function
const simultaneousItems = (catalog, components) => 
  maximize (collect (Object .values (catalog), components))


// sample data
const itemCatalog = { bramble_vest: {components : [ "Chain Vest", "Chain Vest" ], name : "Bramble Vest"}, guardian_angel: {components : [ "B.F. Sword", "Chain Vest" ], name : "Guardian Angel"}, hextech_gunblade: {components : [ "B.F. Sword", "Needlessly Large Rod" ], name : "Hextech Gunblade"}, locket_of_the_iron_solari: {components : [ "Chain Vest", "Needlessly Large Rod" ], name : "Locket of the Iron Solari"}, morellonomicon: {components : [ "Giant's Belt", "Needlessly Large Rod" ], name : "Morellonomicon"}, sunfire_cape: {components : [ "Chain Vest", "Giant's Belt" ], name : "Sunfire Cape"}, zekes_herald: {components : [ "B.F. Sword", "Giant's Belt" ], name : "Zeke's Herald"}}

// const myComponents = ["B.F. Sword", "Chain Vest", "Giant's Belt", "Chain Vest", "Needlessly Large Rod"]
const myComponents = ["B.F. Sword", "Chain Vest", "Giant's Belt", "Chain Vest", "Chain Vest", "Needlessly Large Rod", "Chain Vest"]


// demo
console .log (
  simultaneousItems (itemCatalog, myComponents)
)
.as-console-wrapper {max-height: 100% !important; top: 0}

Adding two more "Chain Vest"s into our components, we now get this result:

[
    ["Bramble Vest", "Bramble Vest", "Hextech Gunblade"],
    ["Bramble Vest", "Bramble Vest", "Morellonomicon"],
    ["Bramble Vest", "Bramble Vest", "Zeke's Herald"],
    ["Bramble Vest", "Guardian Angel", "Locket of the Iron Solari"],
    ["Bramble Vest", "Guardian Angel", "Morellonomicon"],
    ["Bramble Vest", "Guardian Angel", "Sunfire Cape"],
    ["Bramble Vest", "Hextech Gunblade", "Sunfire Cape"],
    ["Bramble Vest", "Locket of the Iron Solari", "Sunfire Cape"],
    ["Bramble Vest", "Locket of the Iron Solari", "Zeke's Herald"],
    ["Guardian Angel", "Locket of the Iron Solari", "Sunfire Cape"]
]

As before, collect is our main function, with simultaneousItems being a simple wrapper that massages the input before calling collect and then running maximize on the result.

Many of the helper functions are the same. Only maximize changed. It now depends on isMultiSubset instead of isSubset (which we no longer need.) But we also have some additional helpers:

  • dropEachRepeatedly drops multiple copies of one list (here the item's components) from another (our available components)

  • howMany reports how many copies of one list can be made from the members of another

  • range simply generates a range of integers. For example

    range (3, 12) //=> [3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
    
  • count counts the occurrences of each value in a list. For example

    count (['a', 'b', 'a', 'c', 'b', 'd', 'b'])
    //=> {a: 2, b: 3, c: 1, d: 1}
    
  • isMultiSubset reports whether one multiset (here expressed as an array, but order doesn't matter) is a subset of another one. For example, ['a' , 'b' , 'a'] is not a multi-subset of ['a', 'b', 'c', 'd'] since there are two 'a's in the first and only one in the second. But it is a multi-subset of ['a', 'b', 'c', 'a'] since there are enough 'a's and 'b' to go around. Because we now allow for multiple copies of components in each output configuration, we need to use this when we do our maximizing.

Our main function, collect now works like this: If we have no items in our input, we return an array containing only the empty array. If we do, we focus on the first component, count how many times it fits in our list of components, then for each value from that number down to zero, we choose to include that many copies of the item, and recur on the remaining items and the components reduced by that many copies of item's component list. We just return a flattened version of this result.

It's quite likely that this code can be simplified. I started from what we already had and altered from there. Often that does not lead to results as good as when we plan it from the start. But many times we don't have that luxury.

Scott Sauyet
  • 49,207
  • 4
  • 49
  • 103
  • relevant: have you ever played league of legends? – Mulan Dec 23 '20 at 15:50
  • the issue of finding only maximal subsets is maximally interesting and maximally frustrating! going to spend more time on this. thanks for sharing all of this, Scott – Mulan Dec 23 '20 at 15:51
  • @Thankyou: No, my idea of video games was stunted not too long after Space Invaders and Asteroids. If I play a game on the computer, it's most likely online chess. – Scott Sauyet Dec 23 '20 at 15:55
  • @Thankyou: my `maximum` implementation is `O (n^2)`, and I do wonder if there is a more efficient one. I haven't thought through the performance of the main function here. While the maximal portion is interesting, I'm more intrigued by the ideas in `collect`. I did write a more generic version that takes two additional functions, one to get the `components` out of an item, one to get a representation to collect. So the above would be `const collect = genericCollect(x => x.components, x => x.name)`, with an obvious implementation. It also solves the related question, but is not worth posting. – Scott Sauyet Dec 23 '20 at 16:01
  • I was originally writing a form similar to `collect` but I wanted a way to remove the iteration duplication caused by a `if (canMake()) ...`. Ie `canMake` does a scan using `.includes` and then `dropEach` repeats the scan using recursion. This is similar to `return myMap.has("someKey") ? myMap.get("someKey") : someDefault`. It always bothered me that `has`+`get` does twice the amount of work necessary. Nullish coalescing operator, `return myMap.get("someKey") ?? someDefault`, works but a better API for Map and Set is desirable. I get around this using CPS in my answer but it's not perfect... – Mulan Dec 23 '20 at 16:17
  • as for `maximize`, I'm afraid we won't see an improvement without that more capable `_Set` or `MultiSet` we've been talking about. I can already tell this problem is going to haunt me for the coming weeks. – Mulan Dec 23 '20 at 16:19
  • @Thankyou: Hot on another problem now, but I'm looking forward to reading your solution! – Scott Sauyet Dec 23 '20 at 16:41
  • @Thankyou: yes! thanks for your thorough and thoughtful answers, guys. I'm digging into both right now. – J. Adam Connor Dec 24 '20 at 01:56
  • I've accepted this as the answer since it's more complete than the originally accepted answer. – J. Adam Connor Dec 25 '20 at 06:19
  • I've just realized that this solution does not account for possible duplicates of items: For example, if you have `["Chain Vest", "Chain Vest", "Chain Vest", "Chain Vest"]` it should produce `["Bramble Vest", "Bramble Vest"]`. But it only produces `["Bramble Vest"]`. I've just started looking into resolving this issue, but I thought I'd give you the opportunity to investigate yourself. – J. Adam Connor Jan 04 '21 at 09:13
  • @J.AdamConnor: Ahh, I didn't think that was a requirement. (I know nothing of the game.) I'm not sure there is an easy patch for this technique. I'll try to think about it soon. – Scott Sauyet Jan 04 '21 at 14:11
  • 1
    @J.AdamConnor: Well, that ate up my lunchtime. But I have an updated version included. – Scott Sauyet Jan 04 '21 at 18:26
  • Thank you. I don't think the concept here is specific to the application. Given an exhaustible list of resources and a "recipe book" it's conceivable that you will always be able to make two or more of the same recipe provided you have sufficient resources. I'm actually surprised I didn't notice this sooner. I suggest making the updated section the only answer since I don't believe it's in any way specially tailored to my specific use case. – J. Adam Connor Jan 06 '21 at 16:04
  • @J.AdamConnor: I do think both have reasonable uses. And there is another generalization where there are limited quantities of each item (there is only enough unobtanium on hand to make two Bramble Vests, even though you have fourteen Chain Vests and could otherwise make seven of them.) The initial case I solved is similar: Here is the stock of items available, one each of Bramble Vest, Guardian Angel, ..., and there is your inventory for barter: four Chain Vests, one B.F. Sword... In fact, this third general version (which I'm not trying now) could be used to replace either version above. – Scott Sauyet Jan 06 '21 at 16:20
2

wishful thinking

Your question caught my attention and I started hacking away at it with one of my favourite programming techniques. Wishful thinking is a way of programming where write your program and wish it were true -

const result =
  make
    ( Object.values(itemCatalog)
    , myComponents
    )

console.log(result)
// ...

Above I wish I could call make with items to make and the available components. A wish made is a wish granted... by you! -

const make = (items, components, i = 0, r = []) =>
  i >= items.length
    ? [ r ]
    : make1
        ( items[i]
        , components
        , (remainder, item) =>
            make
              ( drop(items, i)
              , remainder
              , 0
              , [ ...r, item ]
              )
        , _ => []
        )
        .concat(make(items, components, i + 1, r))

Along the way we make more wishes. The complexity of making a collection of items, make, is separate from the complexity of making a single item, make1. Wish granted -

const make1 = (item, components, ifTrue, ifFalse) =>
  pick
    ( components
    , item.components
    , (remainder, _) => ifTrue(remainder, item.name)
    , ifFalse
    )

Get greedy now. While we're at it, I wish I could pick elements from an array. Wish granted -

const pick = (t1, t2, ifTrue, ifFalse, i = 0) =>
  i >= t2.length
    ? ifTrue(t1, t2)
    : pick1
        ( t1
        , t2[i]
        , (r, _) =>
            pick(r, t2, ifTrue, ifFalse, i + 1)
        , ifFalse 
        )

Ask for a meter, take a parsec. Oh you need to pick a single item from an array? Wish granted by pick1 -

const pick1 = (t, q, ifTrue, ifFalse) =>
  search
    ( t
    , v => v === q
    , (v, i) => ifTrue(drop(t, i), v) 
    , ifFalse
    )

You're not one of those lame genies limited to fulfilling three wishes. If only I could search the array... Wish granted! -

const search = (t, f, ifTrue, ifFalse, i = 0) =>
  i >= t.length
  ? ifFalse()
  : Boolean(f(t[i]))
    ? ifTrue(t[i], i)
    : search(t, f, ifTrue, ifFalse, i + 1)

Grants wishes for days -

const drop = (t, i, n = 1) =>
  t.slice(0, i).concat(t.slice(i + n))

When the wishing ends and the wishes are fulfilled your program is complete. As if by magic -

// output

[ [ 'Bramble Vest', 'Hextech Gunblade' ]
, [ 'Bramble Vest', 'Morellonomicon' ]
, [ 'Bramble Vest', "Zeke's Herald" ]
, [ 'Bramble Vest' ]
, [ 'Guardian Angel', 'Locket of the Iron Solari' ]
, [ 'Guardian Angel', 'Morellonomicon' ]
, [ 'Guardian Angel', 'Sunfire Cape' ]
, [ 'Guardian Angel' ]
, [ 'Hextech Gunblade', 'Bramble Vest' ]
, [ 'Hextech Gunblade', 'Sunfire Cape' ]
, [ 'Hextech Gunblade' ]
, [ 'Locket of the Iron Solari', 'Guardian Angel' ]
, [ 'Locket of the Iron Solari', 'Sunfire Cape' ]
, [ 'Locket of the Iron Solari', "Zeke's Herald" ]
, [ 'Locket of the Iron Solari' ]
, [ 'Morellonomicon', 'Bramble Vest' ]
, [ 'Morellonomicon', 'Guardian Angel' ]
, [ 'Morellonomicon' ]
, [ 'Sunfire Cape', 'Guardian Angel' ]
, [ 'Sunfire Cape', 'Hextech Gunblade' ]
, [ 'Sunfire Cape', 'Locket of the Iron Solari' ]
, [ 'Sunfire Cape' ]
, [ "Zeke's Herald", 'Bramble Vest' ]
, [ "Zeke's Herald", 'Locket of the Iron Solari' ]
, [ "Zeke's Herald" ]
, []
]

The output for make here shows all possible buying options. Writing maximal subsets is real head-scratcher. I'm going to pore over Scott's notes now...


write modules

Organising your code is an important hygienic exercise. Bundling function in modules promotes code reuse and gives us a manageable barrier of abstraction.

Many of the functions we wrote are generic and can be used on any array. Let's put them in a module called arr -

// arr.js

const drop = (t, i, n = 1) =>
  t.slice(0, i).concat(t.slice(i + n))

const pick1 = (t, q, ifTrue, ifFalse) =>
  search
    ( t
    , v => v === q
    , (v, i) => ifTrue(drop(t, i), v) 
    , ifFalse
    )

const pick = (t1, t2, ifTrue, ifFalse, i = 0) =>
  i >= t2.length
    ? ifTrue(t1, t2)
    : pick1
        ( t1
        , t2[i]
        , (r, _) =>
            pick(r, t2, ifTrue, ifFalse, i + 1)
        , ifFalse 
        )

const search = (t, f, ifTrue, ifFalse, i = 0) =>
  i >= t.length
  ? ifFalse()
  : Boolean(f(t[i]))
    ? ifTrue(t[i], i)
    : search(t, f, ifTrue, ifFalse, i + 1)

export { drop, pick, pick1, search }

We'll bundle the item crafting functions into a module called craft. Notice how this module can depend on functionality provided by another module -

// craft.js

import { drop, pick } from "./arr.js"

const make1 = (item, components, ifTrue, ifFalse) =>
  arr.pick
    ( components
    , item.components
    , (remainder, _) => ifTrue(remainder, item.name)
    , ifFalse
    )

const make = (items, components, i = 0, r = []) =>
  i >= items.length
    ? [ r ]
    : make1
        ( items[i]
        , components
        , (remainder, item) =>
            make
              ( arr.drop(items, i)
              , remainder
              , 0
              , [ ...r, item ]
              )
        , _ => []
        )
        .concat(make(items, components, i + 1, r))

export { make, make1 }

Finally write your main module -

// main.js

import { make } from "./craft.js"

const itemCatalog = ...

const myComponents = ...

const result =
  make
    ( Object.values(itemCatalog)
    , myComponents
    )

console.log(result)
// ...
Mulan
  • 129,518
  • 31
  • 228
  • 259
  • 1
    Very interesting. Usually when you post an answer to a question I've also answered, I prefer your take. This time, not so much. CPS is powerful, but its viral nature seems to add complexity to otherwise simple ideas. Still, I love seeing this approach as well. Is there a simple clean-up that would not yield, say, both `[ 'Bramble Vest', 'Hextech Gunblade' ]` and ` [ 'Hextech Gunblade', 'Bramble Vest' ]` in the same output? – Scott Sauyet Dec 23 '20 at 22:56
  • And I very much like the genie approach! – Scott Sauyet Dec 23 '20 at 22:56
  • i welcome the honest feedback. the cps forms presented here are an attempt and a declarative api around the common `const r = someFunc(); if (someCondition(r)) ifTrue(r) else ifFalse(r)` where r requires an intermediate assignment not possible with a single functional expression - very typical is the case of `const r = someArray.find(f) if (r === undefined) notFound() else somethingWith(r)`. now i'm thinking the continuation monad could clean up some of the viral tendrils you describe – Mulan Dec 23 '20 at 23:21
  • and you're right, this solution does not differentiate `[a, b]` from `[b, a]`, yet another issue i have to work out! – Mulan Dec 23 '20 at 23:22
1

I think several functions are required to get the output.

a) A function that says: given required components (for an item), does inventory exists to make the item ?

// are required components (req) a subset of inventory (inv) ?
const canMakeItem = (req, inv) => req.every(r => inv.indexOf(r) > -1);

b) An 'n choose k' function to return an array of item combinations that are to be considered as 'simultaneously createable'. I used this one:

// choose k items from array
const chooseCombos = (arr, k, prefix=[]) => {
  if (k == 0) return [prefix];
  return arr.flatMap((v, i) =>
    chooseCombos(arr.slice(i+1), k-1, [...prefix, v])
  );
}

In your example output, we see pairs of items because this is what the example inputs of the item catalog and component list allow. Given a bigger item catalog and bigger component list then the output would be more than 'pairs' - I try and address that later on in the answer. Using an 'n choose k' function will ultimately allow for testing of combinations of 3, 4, more etc items from the catalog.

c) A function that can take the difference of two arrays (preserving duplicates) to keep track of remaining components as a set of items 'simultaneous creatability' is tested. I used this one:

// array difference with dupes
const remainingComponents = (a, b) => {
  return a.filter(function(v) {
    return !this.get(v) || !this.set(v, this.get(v) - 1);
  }, b.reduce( (acc, v) => acc.set(v, (acc.get(v) || 0) + 1), new Map() ));
}

d) My version of buildSimultaneousItems:

// eliminate combos (arrs[n]) with insufficient inventory (inv) for all items in combo
const createableCombos = (arrs, inv) => { 
  return arrs.filter(arr => {
    // we know arr[0] is possible so remove components
    let currentComponents = remainingComponents(myComponents, itemCatalog[arr[0]].components);
    // check subsequent array items
    for (let i=1; i<arr.length; i++) {
      let requiredComponents = itemCatalog[arr[i]].components;
      if (!canMakeItem(requiredComponents, currentComponents)) {
        // this combo cannot be made from available components
        return false
      } else {
        // remove components from inventory for this item
        currentComponents = remainingComponents(currentComponents, requiredComponents);
      }
    }
    // we can make all the items in this combo!
    return true;
  });
}

The start point here is e.g.

[
  [violin, guitar],
  [guitar, trumpet],
  [violin, trumpet]
]

In that all items of the array are to be tested, but not all are possible e.g. strings used on violin and not available for guitar. The chooseCombos function should eliminate the duplicates e.g. [trumpet, violin].

For each array, assume the first item is createable and remove the required components from inventory. For the rest of the items, repeat the createability test and remove components. If at any point an item cannot be created, then return false for that array and it will be filtered out from the result.

e) Putting it all together:

// eliminate single items not able to be made
// in the example all items can be created
let createableSingleItems = Object.keys(itemCatalog)
    .filter(k => canMakeItem(itemCatalog[k].components, myComponents) );

// candidate pairs - pairs is n choose _2_
let candidatePairs = chooseCombos(createableSingleItems, 2, []);
let createablePairs = createableCombos (candidatePairs, myComponents)

// candidate triples - n choose _3_
let candidateTriples = chooseCombos(createableSingleItems, 3, []);
let createableTriples = createableCombos (candidateTriples, myComponents);

The array of createableSingleItems as a start point will reduce redundant processing later and enable createableCombos to 'know' that arr[0] is 'createable'.

So choosing pairs from the item catalog (where each pair is known to be createable individually):

// candidate pairs - pairs is n choose _2_
let candidatePairs = chooseCombos(createableSingleItems, 2, []);
let createablePairs = createableCombos (candidatePairs, myComponents)

Produces this output:

[
    ["bramble_vest", "hextech_gunblade"],
    ["bramble_vest", "morellonomicon"],
    ["bramble_vest", "zekes_herald"],
    ["guardian_angel", "locket_of_the_iron_solari"],
    ["guardian_angel", "morellonomicon"],
    ["guardian_angel", "sunfire_cape"],
    ["hextech_gunblade", "sunfire_cape"],
    ["locket_of_the_iron_solari", "sunfire_cape"],
    ["locket_of_the_iron_solari", "zekes_herald"]
]

For the named output e.g.

createablePairs.map(a => a.map(b => itemCatalog[b].name));

Gives:

[
    ["Bramble Vest", "Hextech Gunblade"],
    ["Bramble Vest", "Morellonomicon"],
    ["Bramble Vest", "Zeke's Herald"],
    ["Guardian Angel", "Locket of the Iron Solari"],
    ["Guardian Angel", "Morellonomicon"],
    ["Guardian Angel", "Sunfire Cape"],
    ["Hextech Gunblade", "Sunfire Cape"],
    ["Locket of the Iron Solari", "Sunfire Cape"],
    ["Locket of the Iron Solari", "Zeke's Herald"]
]

Choosing triples (n choose 3) from the item catalog gives [] because we don't have enough inventory...

The maximum choice for n choose k is k == n == max(catalog). For a very large catalog and a very large inventory, it may be optimisation is required for this method. For a very large catalog and a relatively small inventory, the preparatory step of createableSingleItems may suffice.

const itemCatalog = {
    "bramble_vest" : {
        "components" : [ "Chain Vest", "Chain Vest" ],
        "name" : "Bramble Vest"
    },
    "guardian_angel" : {
        "components" : [ "B.F. Sword", "Chain Vest" ],
        "name" : "Guardian Angel"
    },
    "hextech_gunblade" : {
        "components" : [ "B.F. Sword", "Needlessly Large Rod" ],
        "name" : "Hextech Gunblade"
    },
    "locket_of_the_iron_solari" : {
        "components" : [ "Chain Vest", "Needlessly Large Rod" ],
        "name" : "Locket of the Iron Solari"
    },
    "morellonomicon" : {
        "components" : [ "Giant's Belt", "Needlessly Large Rod" ],
        "name" : "Morellonomicon"
    },
    "sunfire_cape" : {
        "components" : [ "Chain Vest", "Giant's Belt" ],
        "name" : "Sunfire Cape"
    },
    "zekes_herald" : {
        "components" : [ "B.F. Sword", "Giant's Belt" ],
        "name" : "Zeke's Herald"
    }
}

let myComponents = [
    "B.F. Sword",
    "Chain Vest",
    "Giant's Belt",
    "Chain Vest",
    "Needlessly Large Rod"
]

// are required components (req) a subset of inventory (inv) ?
const canMakeItem = (req, inv) => req.every(r => inv.indexOf(r) > -1);

// https://stackoverflow.com/questions/64414816/can-you-return-n-choose-k-combinations-in-javascript-using-array-flatmap
// choose k items from array
const chooseCombos = (arr, k, prefix=[]) => {
  if (k == 0) return [prefix];
  return arr.flatMap((v, i) =>
    chooseCombos(arr.slice(i+1), k-1, [...prefix, v])
  );
}

// https://stackoverflow.com/questions/39810641/get-difference-between-two-arrays-including-duplicates
// array difference with dupes
const remainingComponents = (a, b) => {
  return a.filter(function(v) {
    return !this.get(v) || !this.set(v, this.get(v) - 1);
  }, b.reduce( (acc, v) => acc.set(v, (acc.get(v) || 0) + 1), new Map() ));
}

// eliminate combos (arrs[n]) with insufficient inventory (inv) for all items in combo
const createableCombos = (arrs, inv) => { 
  return arrs.filter(arr => {
    // we know arr[0] is possible so remove components
    let currentComponents = remainingComponents(myComponents, itemCatalog[arr[0]].components);
    // check subsequent array items
    for (let i=1; i<arr.length; i++) {
      let requiredComponents = itemCatalog[arr[i]].components;
      if (!canMakeItem(requiredComponents, currentComponents)) {
        // this combo cannot be made from available components
        return false
      } else {
        // remove components from inventory for this item
        currentComponents = remainingComponents(currentComponents, requiredComponents);
      }
    }
    // we can make all the items in this combo!
    return true;
  });
}

// eliminate single items not able to be made
// in the example all items can be created
let createableSingleItems = Object.keys(itemCatalog)
    .filter(k => canMakeItem(itemCatalog[k].components, myComponents) );

// candidate pairs - pairs is n choose _2_
let candidatePairs = chooseCombos(createableSingleItems, 2, []);
let createablePairs = createableCombos (candidatePairs, myComponents)

// candidate triples - n choose _3_
let candidateTriples = chooseCombos(createableSingleItems, 3, []);
let createableTriples = createableCombos (candidateTriples, myComponents);

// test 
console.log("Pairs:");
console.log(createablePairs );
console.log("Triples:");
console.log(createableTriples);
Robin Mackenzie
  • 18,801
  • 7
  • 38
  • 56
  • Hi! Thank you for your work! It definitely produces the correct output, but one thing confuses me a bit. How many items are simultaneously creatable is dependent on how many components there are in the inventory. For example, if there are 4 components in the inventory, 2 items will always be simultaneously creatable. If there are 6 components, 3 items will always be simultaneously possible, and so on. So the second paramater in chooseCombos() shouldn't be necessary. – J. Adam Connor Dec 22 '20 at 06:02
  • One way to make this produce the correct output without manually having to input k would be to use `Math.floor(myComponents.length /2)` in the second paramater for `chooseCombos()` – J. Adam Connor Dec 22 '20 at 06:31
  • Hi. Yes it's not clear the relationship between number of components and number of items in the catalog. For the example data, there's 7 items, 5 components and output is in pairs. If the output is to be in triples where number of components is 6 or 7 then yes this would work. Honestly wasn't sure if you had every growing inventory and every growing items to craft etc. Sounds like you have semi-linear relationship between inventory and crafting items ? – Robin Mackenzie Dec 22 '20 at 08:28
  • Yeah, so all catalog items will only ever require only two components. Therefore the number of simultaneously creatable items will always be double the number of components. – J. Adam Connor Dec 22 '20 at 09:29
  • it's more proper to write `acc.get(v) || 0` using nullish coalescing operator now, `acc.get(v) ?? 0`. there's not an impact on this particular program, but `someExpression || someDefault` runs into some issues when `someExpression` is present but also falsey. – Mulan Dec 23 '20 at 16:22