Index: binaries/data/mods/public/simulation/helpers/MultiKeyMap.js =================================================================== --- /dev/null +++ binaries/data/mods/public/simulation/helpers/MultiKeyMap.js @@ -0,0 +1,241 @@ +// Convenient container abstraction for storing items referenced by a 3-tuple. +// Used by the itemsManager to store items by (property Name, entity, item ID). +// Methods starting with an underscore are private to the storage. +// This supports stackable items as it stores count for each 3-tuple. +function MultiKeyMap() +{ + this.items = new Map(); + // Keys are referred to as 'primaryKey', 'secondaryKey', 'itemID'. +} + +MultiKeyMap.prototype.Serialize = function() +{ + let ret = []; + for (let primary of this.items.keys()) + { + // Keys of a Map can be arbitrary types whereas objects only support string, so use a list. + let vals = [primary, []]; + ret.push(vals); + for (let secondary of this.items.get(primary).keys()) + vals[1].push([secondary, this.items.get(primary).get(secondary)]); + } + return ret; +}; + +MultiKeyMap.prototype.Deserialize = function(data) +{ + for (let primary in data) + { + this.items.set(data[primary][0], new Map()); + for (let secondary in data[primary][1]) + this.items.get(data[primary][0]).set(data[primary][1][secondary][0], data[primary][1][secondary][1]); + } +}; + +/** + * Add a single item. + * NB: if you add an item with a different value but the same itemID, the original value remains. + * @param itemID - internal ID of this item, for later removal and/or updating + * @param stackable - if stackable, changing the count of items invalides, otherwise not. + * @returns true if the items list changed in such a way that cached values are possibly invalidated. + */ +MultiKeyMap.prototype.AddItem = function(primaryKey, itemID, item, secondaryKey, stackable = false) +{ + if (!this._AddItem(primaryKey, itemID, item, secondaryKey, stackable)) + return false; + + this._OnItemModified(primaryKey, secondaryKey, itemID); + return true; +}; + +/** + * Add items to multiple properties at once (only one item per property) + * @param items - Dictionnary of { primaryKey: item } + * @returns true if the items list changed in such a way that cached values are possibly invalidated. + */ +MultiKeyMap.prototype.AddItems = function(itemID, items, secondaryKey, stackable = false) +{ + let modified = false; + for (let primaryKey in items) + modified = this.AddItem(primaryKey, itemID, items[primaryKey], secondaryKey, stackable) || modified; + return modified; +}; + +/** + * Removes a item on a property. + * @param primaryKey - property to change (e.g. "Health/Max") + * @param itemID - internal ID of the item to remove + * @param secondaryKey - secondaryKey ID + * @returns true if the items list changed in such a way that cached values are possibly invalidated. + */ +MultiKeyMap.prototype.RemoveItem = function(primaryKey, itemID, secondaryKey, stackable = false) +{ + if (!this._RemoveItem(primaryKey, itemID, secondaryKey, stackable)) + return false; + + this._OnItemModified(primaryKey, secondaryKey, itemID); + return true; +}; + +/** + * Removes items with this ID for any property name. + * Naively iterates all property names. + * @returns true if the items list changed in such a way that cached values are possibly invalidated. + */ +MultiKeyMap.prototype.RemoveAllItems = function(itemID, secondaryKey, stackable = false) +{ + let modified = false; + // Map doesn't implement some so use a for-loop here. + for (let primaryKey of this.items.keys()) + modified = this.RemoveItem(primaryKey, itemID, secondaryKey, stackable) || modified; + return modified; +}; + +/** + * @param itemID - internal ID of the item to try and find. + * @returns true if there is at least one item with that itemID + */ +MultiKeyMap.prototype.HasItem = function(primaryKey, itemID, secondaryKey) +{ + // some() returns false for an empty list which is wanted here. + return this._getItems(primaryKey, secondaryKey).some(item => item.ID === itemID); +}; + +/** + * Check if we have a item for any property name. + * Naively iterates all property names. + * @returns true if there is at least one item with that itemID + */ +MultiKeyMap.prototype.HasAnyItem = function(itemID, secondaryKey) +{ + // Map doesn't implement some so use for loops instead. + for (let primaryKey of this.items.keys()) + if (this.HasItem(primaryKey, itemID, secondaryKey)) + return true; + return false; +}; + +/** + * @returns A list of items with storage metadata removed. + */ +MultiKeyMap.prototype.GetItems = function(primaryKey, secondaryKey, stackable = false) +{ + let items = []; + + if (stackable) + this._getItems(primaryKey, secondaryKey).forEach(item => (items = items.concat(Array(item.count).fill(item.value)))); + else + this._getItems(primaryKey, secondaryKey).forEach(item => items.push(item.value)); + + return items; +}; + +/** + * @returns A dictionary of { Property Name: items } for the secondary Key. + * Naively iterates all property names. + */ +MultiKeyMap.prototype.GetAllItems = function(secondaryKey, stackable = false) +{ + let items = {}; + + // Map doesn't implement filter so use a for loop. + for (let primaryKey of this.items.keys()) + { + if (!this.items.get(primaryKey).has(secondaryKey)) + continue; + items[primaryKey] = this.GetItems(primaryKey, secondaryKey, stackable); + } + return items; +}; + +/** + * @returns a list of items. + * This does not necessarily return a reference to items' list, use _getItemsOrInit for that. + */ +MultiKeyMap.prototype._getItems = function(primaryKey, secondaryKey) +{ + if (!this._exists(primaryKey, secondaryKey)) + return []; + return this.items.get(primaryKey).get(secondaryKey); +}; + +/** + * @returns a reference to the list of items for that property name and secondaryKey. + */ +MultiKeyMap.prototype._getItemsOrInit = function(primaryKey, secondaryKey) +{ + if (!this._exists(primaryKey, secondaryKey)) + this._initItemsIfNeeded(primaryKey, secondaryKey); + return this.items.get(primaryKey).get(secondaryKey); +}; + +MultiKeyMap.prototype._exists = function(primaryKey, secondaryKey) +{ + if (!this.items.has(primaryKey)) + return false; + if (!this.items.get(primaryKey).has(secondaryKey)) + return false; + return true; +}; + +MultiKeyMap.prototype._initItemsIfNeeded = function(primaryKey, secondaryKey) +{ + if (!this.items.get(primaryKey)) + this.items.set(primaryKey, new Map()); + if (!this.items.get(primaryKey).get(secondaryKey)) + this.items.get(primaryKey).set(secondaryKey, []); +}; + +/** + * @returns true if the items list changed in such a way that cached values are possibly invalidated. + */ +MultiKeyMap.prototype._AddItem = function(primaryKey, itemID, item, secondaryKey, stackable) +{ + let items = this._getItemsOrInit(primaryKey, secondaryKey); + for (let it of items) + if (it.ID == itemID) + { + it.count++; + return stackable; + } + + items.push({ "ID": itemID, "count": 1, "value": item }); + return true; +}; + +/** + * @returns true if the items list changed in such a way that cached values are possibly invalidated. + */ +MultiKeyMap.prototype._RemoveItem = function(primaryKey, itemID, secondaryKey, stackable) +{ + let items = this._getItems(primaryKey, secondaryKey); + + let existingItem = items.filter(item => { return item.ID == itemID; }); + if (!existingItem.length) + return false; + + if (--existingItem[0].count > 0) + return stackable; + + let stilValidItems = items.filter(item => item.count > 0); + + // Delete entries from the map if necessary to clean up. + if (!stilValidItems.length) + { + this.items.get(primaryKey).delete(secondaryKey); + if (!this.items.get(primaryKey).size) + this.items.delete(primaryKey); + return true; + } + + this.items.get(primaryKey).set(secondaryKey, stilValidItems); + + return true; +}; + +/** + * Stub method, to overload. + */ +MultiKeyMap.prototype._OnItemModified = function(primaryKey, secondaryKey, itemID) {}; + +Engine.RegisterGlobal("MultiKeyMap", MultiKeyMap); Index: binaries/data/mods/public/simulation/helpers/tests/test_MultiKeyMap.js =================================================================== --- /dev/null +++ binaries/data/mods/public/simulation/helpers/tests/test_MultiKeyMap.js @@ -0,0 +1,132 @@ +Engine.LoadHelperScript("MultiKeyMap.js"); + +function setup_keys(map) +{ + map.AddItem("prim_a", "item_a", 0, "sec_a"); + map.AddItem("prim_a", "item_b", 0, "sec_a"); + map.AddItem("prim_a", "item_c", 0, "sec_a"); + map.AddItem("prim_a", "item_a", 0, "sec_b"); + map.AddItem("prim_b", "item_a", 0, "sec_a"); + map.AddItem("prim_c", "item_a", 0, "sec_a"); + map.AddItem("prim_c", "item_a", 0, 5); +} + +// Check that key-related operations are correct. +function test_keys(map) +{ + TS_ASSERT(map.items.has("prim_a")); + TS_ASSERT(map.items.has("prim_b")); + TS_ASSERT(map.items.has("prim_c")); + + TS_ASSERT(map.items.get("prim_a").has("sec_a")); + TS_ASSERT(map.items.get("prim_a").has("sec_b")); + TS_ASSERT(!map.items.get("prim_a").has("sec_c")); + TS_ASSERT(map.items.get("prim_b").has("sec_a")); + TS_ASSERT(map.items.get("prim_c").has("sec_a")); + TS_ASSERT(map.items.get("prim_c").has(5)); + + TS_ASSERT(map.items.get("prim_a").get("sec_a").length == 3); + TS_ASSERT(map.items.get("prim_a").get("sec_b").length == 1); + TS_ASSERT(map.items.get("prim_b").get("sec_a").length == 1); + TS_ASSERT(map.items.get("prim_c").get("sec_a").length == 1); + TS_ASSERT(map.items.get("prim_c").get(5).length == 1); + + TS_ASSERT(map.GetItems("prim_a", "sec_a").length == 3); + TS_ASSERT(map.GetItems("prim_a", "sec_b").length == 1); + TS_ASSERT(map.GetItems("prim_b", "sec_a").length == 1); + TS_ASSERT(map.GetItems("prim_c", "sec_a").length == 1); + TS_ASSERT(map.GetItems("prim_c", 5).length == 1); + + TS_ASSERT(map.HasItem("prim_a", "item_a", "sec_a")); + TS_ASSERT(map.HasItem("prim_a", "item_b", "sec_a")); + TS_ASSERT(map.HasItem("prim_a", "item_c", "sec_a")); + TS_ASSERT(!map.HasItem("prim_a", "item_d", "sec_a")); + TS_ASSERT(map.HasItem("prim_a", "item_a", "sec_b")); + TS_ASSERT(!map.HasItem("prim_a", "item_b", "sec_b")); + TS_ASSERT(!map.HasItem("prim_a", "item_c", "sec_b")); + TS_ASSERT(map.HasItem("prim_b", "item_a", "sec_a")); + TS_ASSERT(map.HasItem("prim_c", "item_a", "sec_a")); + TS_ASSERT(map.HasAnyItem("item_a", "sec_b")); + TS_ASSERT(map.HasAnyItem("item_b", "sec_a")); + TS_ASSERT(!map.HasAnyItem("item_d", "sec_a")); + TS_ASSERT(!map.HasAnyItem("item_b", "sec_b")); + + // Adding the same item increases its count. + map.AddItem("prim_a", "item_b", 0, "sec_a"); + TS_ASSERT(map.items.get("prim_a").get("sec_a").length == 3); + TS_ASSERT(map.items.get("prim_a").get("sec_a").filter(item => item.ID == "item_b")[0].count == 2); + TS_ASSERT(map.GetItems("prim_a", "sec_a").length == 3); + TS_ASSERT(map.GetItems("prim_a", "sec_a", true).length == 4); + + // Adding without stackable doesn't invalidate caches, adding with does. + TS_ASSERT(!map.AddItem("prim_a", "item_b", 0, "sec_a")); + TS_ASSERT(map.AddItem("prim_a", "item_b", 0, "sec_a", true)); + + TS_ASSERT(map.items.get("prim_a").get("sec_a").filter(item => item.ID == "item_b")[0].count == 4); + + // Likewise removing, unless we now reach 0 + TS_ASSERT(!map.RemoveItem("prim_a", "item_b", "sec_a")); + TS_ASSERT(map.RemoveItem("prim_a", "item_b", "sec_a", true)); + TS_ASSERT(!map.RemoveItem("prim_a", "item_b", "sec_a")); + TS_ASSERT(map.RemoveItem("prim_a", "item_b", "sec_a")); + + // Check that cleanup is done + TS_ASSERT(map.items.get("prim_a").get("sec_a").length == 2); + TS_ASSERT(map.RemoveItem("prim_a", "item_a", "sec_a")); + TS_ASSERT(map.RemoveItem("prim_a", "item_c", "sec_a")); + TS_ASSERT(!map.items.get("prim_a").has("sec_a")); + TS_ASSERT(map.items.get("prim_a").has("sec_b")); + TS_ASSERT(map.RemoveItem("prim_a", "item_a", "sec_b")); + TS_ASSERT(!map.items.has("prim_a")); +} + +function setup_items(map) +{ + map.AddItem("prim_a", "item_a", 1, "sec_a"); + map.AddItem("prim_a", "item_b", 2, "sec_a"); + map.AddItem("prim_a", "item_c", 3, "sec_a"); + map.AddItem("prim_a", "item_c", 1000, "sec_a"); + map.AddItem("prim_a", "item_a", 5, "sec_b"); + map.AddItem("prim_b", "item_a", 6, "sec_a"); + map.AddItem("prim_c", "item_a", 7, "sec_a"); +} + +// Check that items returned are correct. +function test_items(map) +{ + let items = map.GetAllItems("sec_a"); + TS_ASSERT("prim_a" in items); + TS_ASSERT("prim_b" in items); + TS_ASSERT("prim_c" in items); + let sum = 0; + for (let key in items) + items[key].forEach(item => (sum += item)); + TS_ASSERT(sum == 19); + + items = map.GetAllItems("sec_a", true); + sum = 0; + for (let key in items) + items[key].forEach(item => (sum += item)); + // We're adding more of the first item_c, the value wasn't replaced. + TS_ASSERT(sum == 22); +} + +// Test items, and test that deserialised versions still pass test (i.e. test serialisation). +let map = new MultiKeyMap(); +setup_keys(map); +test_keys(map); + +map = new MultiKeyMap(); +let map2 = new MultiKeyMap(); +setup_keys(map); +map2.Deserialize(map.Serialize()); +test_keys(map2); + +map = new MultiKeyMap(); +setup_items(map); +test_items(map); +map = new MultiKeyMap(); +map2 = new MultiKeyMap(); +setup_items(map); +map2.Deserialize(map.Serialize()); +test_items(map2); Index: source/simulation2/components/tests/test_scripts.h =================================================================== --- source/simulation2/components/tests/test_scripts.h +++ source/simulation2/components/tests/test_scripts.h @@ -66,6 +66,7 @@ VfsPaths paths; TS_ASSERT_OK(vfs::GetPathnames(g_VFS, L"simulation/components/tests/", L"test_*.js", paths)); + TS_ASSERT_OK(vfs::GetPathnames(g_VFS, L"simulation/helpers/tests/", L"test_*.js", paths)); paths.push_back(VfsPath(L"simulation/components/tests/setup_test.js")); for (const VfsPath& path : paths) {