From fe9dba4f9705d94c034f28a6e5e5f1720fca1d78 Mon Sep 17 00:00:00 2001 From: Joel Alejandro Villarreal Bertoldi Date: Fri, 20 Sep 2019 23:12:41 -0300 Subject: [PATCH] [inventory] Implements a first version of the inventory module (#79) * added @xethya/utils * implemented basic structure with slotCapacity * added placeholder Item class * updated to utils@0.1.7 * implemented minimum `contents` attr * updaded to utils@0.1.8 * finished first round of implementation * starting to work on inventory tests * added isFull(), isEmpty(), stack resizing * added tests * bumped inventory to 0.0.2 --- packages/inventory/package.json | 7 +- packages/inventory/src/inventory.ts | 293 ++++++++++++++++++++- packages/inventory/src/item.ts | 40 +++ packages/inventory/tests/inventory.test.ts | 183 ++++++++++++- 4 files changed, 518 insertions(+), 5 deletions(-) create mode 100644 packages/inventory/src/item.ts diff --git a/packages/inventory/package.json b/packages/inventory/package.json index 0708b74..df37609 100644 --- a/packages/inventory/package.json +++ b/packages/inventory/package.json @@ -1,7 +1,7 @@ { "name": "@xethya/inventory", "shortName": "inventory", - "version": "0.0.1", + "version": "0.0.2", "description": "", "main": "dist/xethya.inventory.js", "module": "dist/xethya.inventory.es.js", @@ -16,6 +16,7 @@ ], "devDependencies": { "@types/jest": "^24.0.18", + "@types/uuid": "^3.4.5", "jest": "^24.9.0", "ts-jest": "^24.0.2", "tslint": "^5.19.0", @@ -39,5 +40,9 @@ "transformIgnorePatterns": [ "/node_modules/[^@xethya]/" ] + }, + "dependencies": { + "@xethya/utils": "^0.1.8", + "uuid": "^3.3.3" } } diff --git a/packages/inventory/src/inventory.ts b/packages/inventory/src/inventory.ts index bc71c14..2eacfb1 100644 --- a/packages/inventory/src/inventory.ts +++ b/packages/inventory/src/inventory.ts @@ -1 +1,292 @@ -export class Inventory {} +import { assert, DynamicWeightedStack, Stack } from "@xethya/utils"; +import { Item } from "./item"; + +/** + * By default, any inventory supports a maximum capacity + * of 100 weight units. + */ +export const INVENTORY_DEFAULT_CAPACITY = 100; + +/** + * A default capacity provider to use the default capacity + * for inventories. + */ +export const INVENTORY_DEFAULT_CAPACITY_PROVIDER = () => INVENTORY_DEFAULT_CAPACITY; + +/** + * A function used to calculate this inventory's capacity. + * Its scope is the inventory's instance. + * + * @this {Inventory} + */ +export type InventoryCapacityProvider = (this: Inventory) => number; + +/** + * Customizes the inventory's features. + */ +export type InventoryOptions = { + /** + * A function used to calculate this inventory's capacity. + * Its scope is the inventory's instance. Defaults to a basic + * provider that always return 100. + * + * @default INVENTORY_DEFAULT_CAPACITY_PROVIDER + */ + capacityProvider?: InventoryCapacityProvider; +}; + +/** + * A lookup result for an entry in the inventory's index. + */ +export type InventoryIndexEntry = { + /** + * Used to preserve the inventory's order. + */ + position: number; + + /** + * A reference to the item itself. + */ + item: Item; +}; + +/** + * Allows to lookup an item by its ID. + */ +export type InventoryIndex = { [key: string]: InventoryIndexEntry }; + +export class Inventory { + /** + * Contains a list of all the items stored in this inventory. + */ + protected readonly contents: DynamicWeightedStack; + + /** + * Allows to quickly lookup an item by its unique identifier. + */ + protected readonly index: InventoryIndex; + + /** + * A function used to calculate this inventory's capacity. + * Its scope is the inventory's instance. + * + * @this {Inventory} + */ + protected readonly capacityProvider: InventoryCapacityProvider; + + protected lastCalculatedCapacity: number; + + /** + * Counts how many items have been stored in the inventory. + * + * @todo Move this to the DynamicWeightedStack (or maybe any Stack?). + */ + protected count: number = 0; + + /** + * Indicates how full this inventory is. + * + * @todo Move this to the DynamicWeightedStack (or maybe any Stack?). + */ + protected occupiedCapacity: number = 0; + + /** + * Allows to keep track of items placed in a container, up to + * a certain capacity. + * + * @param options {InventoryOptions} + */ + constructor(options: InventoryOptions = {}) { + this.capacityProvider = options.capacityProvider || INVENTORY_DEFAULT_CAPACITY_PROVIDER; + this.contents = new DynamicWeightedStack(this.capacity, "weight"); + this.index = {}; + } + + /** + * Indicates the current capacity of the inventory. If the capacity has + * changed since the last time it was calculate, it'll autoresize the + * inventory's stack. + */ + public get capacity(): number { + const capacity = this.capacityProvider.bind(this)(); + + if (this.lastCalculatedCapacity && this.lastCalculatedCapacity !== capacity) { + this.contents.resize(this.lastCalculatedCapacity); + } + + this.lastCalculatedCapacity = capacity; + + return this.lastCalculatedCapacity; + } + + /** + * Returns the index entries as an array of values. + */ + protected get indexEntries(): InventoryIndexEntry[] { + return Object.values(this.index); + } + + /** + * Returns the available capacity on this inventory. + */ + getAvailableSpace(): number { + return this.capacity - this.occupiedCapacity; + } + + /** + * Puts an item or items inside this inventory. This will affect + * the inventory's occupied capacity. + * + * @param items {...Item} + */ + put(...items: Item[]): void { + items.forEach(item => { + this.contents.push(item); + this.index[item.id] = { position: this.count, item }; + + // TODO: These could be exposed from the stack, removing the need for extra state. + this.count += 1; + this.occupiedCapacity += item.weight; + }); + } + + /** + * Returns information about an item in the inventory, + * looking it up by its unique identifier. This will not + * have any impact on the inventory's occupied capacity. + * + * @param id {string} + */ + peek(id: string): Item | void { + return this.index[id] ? this.index[id].item : undefined; + } + + /** + * Returns information about an item in the inventory, + * looking it up by its numeric position. This will not + * have any impact on the inventory's occupied capacity. + * + * @param position {number} + */ + peekAt(position: number): Item | void { + if (this.isEmpty()) { + return; + } + + const indexEntry = this.getByPosition(position); + + if (!indexEntry) { + return; + } + + return indexEntry.item; + } + + /** + * Returns information about *all* of this inventory's + * items as an array. + */ + peekAll(): Item[] { + if (this.isEmpty()) { + return []; + } + + const items: Item[] = []; + this.indexEntries.forEach(({ position, item }) => (items[position] = item)); + return items; + } + + /** + * Extracts an item from the inventory by its unique identifier. + * This *will* affect the inventory's occupied capacity. + * + * @param id {string} + */ + retrieve(id: string): Item | void { + if (this.isEmpty()) { + return; + } + + const item = this.peek(id); + + if (!item) { + return; + } + + this.extractFromContents(item); + + return item; + } + + /** + * Extracts an item from the inventory by its position. + * This *will* affect the inventory's occupied capacity. + * + * @param id {string} + */ + retrieveAt(position: number): Item | void { + if (this.isEmpty()) { + return; + } + + const item = this.peekAt(position); + + if (!item) { + return; + } + + this.extractFromContents(item); + + return item; + } + + /** + * Returns `true` if the inventory is full, `false` if it's not. + */ + isFull(): boolean { + return this.getAvailableSpace() === 0; + } + + /** + * Returns `true` if the inventory is empty, `false` if it's not. + */ + isEmpty(): boolean { + return this.getAvailableSpace() === this.capacity; + } + + /** + * Removes an item from the inventory and adjusts the capacity + * accordingly. + * + * @param itemToRetrieve {Item} + */ + protected extractFromContents(itemToRetrieve: Item) { + const temporaryStack = new Stack(); + + while (temporaryStack.peek() !== itemToRetrieve && !this.contents.isEmpty()) { + temporaryStack.push(this.contents.pop() as Item); + } + + temporaryStack.pop(); + delete this.index[itemToRetrieve.id]; + + while (!temporaryStack.isEmpty()) { + const item = temporaryStack.pop() as Item; + this.contents.push(item); + this.index[item.id].position -= 1; + } + + this.count -= 1; + this.occupiedCapacity -= itemToRetrieve.weight; + } + + /** + * Returns an item by its position in the inventory index. + * + * @param position {number} + */ + protected getByPosition(position: number) { + assert(position >= 0, "A non-negative index must be used to access the inventory by position"); + return this.indexEntries.find(entry => entry.position === position); + } +} diff --git a/packages/inventory/src/item.ts b/packages/inventory/src/item.ts new file mode 100644 index 0000000..5cd18c7 --- /dev/null +++ b/packages/inventory/src/item.ts @@ -0,0 +1,40 @@ +import { v4 as generateUUID } from "uuid"; + +export const ITEM_DEFAULT_WEIGHT = 1; + +/** + * Customizes the item's features. + */ +export type ItemOptions = { + /** + * How much this item weights. Defaults to ITEM_DEFAULT_WEIGHT (1). + * + * @default 1 + */ + weight?: number; +}; + +export class Item { + /** + * A unique identifier for the item. + */ + public readonly id: string; + + /** + * How much this item weights. Defaults to ITEM_DEFAULT_WEIGHT (1). + * + * @default 1 + */ + public readonly weight: number; + + /** + * Represents something an entity can hold and/or use. + * + * @param options {ItemOptions} + * @todo Decouple this into a package of its own. + */ + constructor(options: ItemOptions = {}) { + this.id = generateUUID(); + this.weight = options.weight || ITEM_DEFAULT_WEIGHT; + } +} diff --git a/packages/inventory/tests/inventory.test.ts b/packages/inventory/tests/inventory.test.ts index ce621d5..aa68c3b 100644 --- a/packages/inventory/tests/inventory.test.ts +++ b/packages/inventory/tests/inventory.test.ts @@ -1,7 +1,184 @@ -import { Inventory } from "../src/inventory"; +import { Inventory, INVENTORY_DEFAULT_CAPACITY } from "../src/inventory"; +import { Item } from "../src/item"; describe("Inventory", () => { - it("can be instantiated", () => { - expect(new Inventory()).toBeDefined(); + describe("Basic instantiation", () => { + it("can be instantiated with default settings", () => { + const inventory = new Inventory(); + expect(inventory.capacity).toEqual(INVENTORY_DEFAULT_CAPACITY); + expect(inventory.getAvailableSpace()).toEqual(INVENTORY_DEFAULT_CAPACITY); + }); + + it("can be instantiated with a custom capacity", () => { + const inventory = new Inventory({ + capacityProvider: () => 50, + }); + expect(inventory.capacity).toEqual(50); + expect(inventory.getAvailableSpace()).toEqual(50); + }); + + it("can use a dynamic custom capacity", () => { + let dynamicFactor = 2; + const capacityProvider = () => dynamicFactor * 10; + const inventory = new Inventory({ capacityProvider }); + expect(inventory.capacity).toEqual(20); + dynamicFactor = 5; + expect(inventory.capacity).toEqual(50); + }); + }); + + describe("Storage and Peeking", () => { + let inventory: Inventory; + + beforeEach(() => { + inventory = new Inventory(); + }); + + it("can put an item and peek it by its ID", () => { + const item = new Item(); + inventory.put(item); + expect(inventory.getAvailableSpace()).toEqual(99); + + const storedItem = inventory.peek(item.id) as Item; + expect(storedItem).toEqual(item); + }); + + it("can put multiple items and peek at them by their ID", () => { + const itemA = new Item({ weight: 5 }); + const itemB = new Item({ weight: 12 }); + inventory.put(itemA, itemB); + + const storedItemA = inventory.peek(itemA.id) as Item; + const storedItemB = inventory.peek(itemB.id) as Item; + + expect(inventory.getAvailableSpace()).toEqual(83); + + expect(storedItemA).toEqual(itemA); + expect(storedItemB).toEqual(itemB); + }); + + it("can put an item and peek it by its position", () => { + const item = new Item({ weight: 5 }); + inventory.put(item); + + const storedItem = inventory.peekAt(0); + expect(storedItem).toEqual(item); + }); + + it("can put multiple items and peek at them by their position", () => { + const itemA = new Item({ weight: 5 }); + const itemB = new Item({ weight: 12 }); + inventory.put(itemA, itemB); + + const storedItemA = inventory.peekAt(0) as Item; + const storedItemB = inventory.peekAt(1) as Item; + + expect(storedItemA).toEqual(itemA); + expect(storedItemB).toEqual(itemB); + }); + + it("can put multiple items and peek them all at once", () => { + const itemA = new Item({ weight: 5 }); + const itemB = new Item({ weight: 12 }); + inventory.put(itemA, itemB); + + const [storedItemA, storedItemB] = inventory.peekAll(); + + expect(storedItemA).toEqual(itemA); + expect(storedItemB).toEqual(itemB); + }); + + it("can put an item, fill the inventory, resize its capacity and add a new item", () => { + let dynamicFactor = 2; + const dynamicInventory = new Inventory({ capacityProvider: () => dynamicFactor * 2 }); + dynamicInventory.put(new Item({ weight: 4 })); + expect(dynamicInventory.isFull()).toEqual(true); + dynamicFactor = 3; + expect(dynamicInventory.isFull()).toEqual(false); + dynamicInventory.put(new Item({ weight: 2 })); + expect(dynamicInventory.isFull()).toEqual(true); + }); + + it("returns undefined when peeking non-existing items", () => { + expect(inventory.peek("foo")).toEqual(undefined); + }); + + it("returns undefined when peeking non-existing positions", () => { + expect(inventory.peekAt(1000)).toEqual(undefined); + }); + + it("returns an empty array when peeking an empty inventory", () => { + expect(inventory.peekAll()).toEqual([]); + }); + + it("throws an error when peeking by a less-than-zero index on a non-empty inventory", () => { + inventory.put(new Item()); + expect(() => inventory.peekAt(-1)).toThrow(/non-negative index/); + }); + + it("returns true when calling isEmpty() if the inventory has no items", () => { + expect(inventory.isEmpty()).toEqual(true); + }); + + it("returns false when calling isEmpty() if the inventory has items", () => { + inventory.put(new Item()); + expect(inventory.isEmpty()).toEqual(false); + }); + + it("returns true when calling isFull() if the inventory has reached its capacity", () => { + inventory.put(new Item({ weight: inventory.capacity })); + expect(inventory.getAvailableSpace()).toEqual(0); + expect(inventory.isFull()).toEqual(true); + }); + + it("returns false when calling isFull() if the inventory has not reached its capacity", () => { + inventory.put(new Item({ weight: inventory.capacity / 2 })); + expect(inventory.getAvailableSpace()).toBeGreaterThan(0); + expect(inventory.isFull()).toEqual(false); + }); + }); + + describe("Retrieval", () => { + let inventory: Inventory; + const itemA = new Item({ weight: 5 }); + const itemB = new Item({ weight: 12 }); + const itemC = new Item({ weight: 3 }); + + beforeEach(() => { + inventory = new Inventory(); + inventory.put(itemA, itemB, itemC); + }); + + it("can retrieve an item by its ID", () => { + const availableSpaceBeforeRetrieval = inventory.getAvailableSpace(); + const retrievedItemA = inventory.retrieve(itemA.id) as Item; + const availableSpaceAfterRetrieval = inventory.getAvailableSpace(); + expect(retrievedItemA).toEqual(itemA); + expect(availableSpaceAfterRetrieval - availableSpaceBeforeRetrieval).toEqual(retrievedItemA.weight); + }); + + it("can retrieve an item by its position", () => { + const availableSpaceBeforeRetrieval = inventory.getAvailableSpace(); + const retrievedItemB = inventory.retrieveAt(1) as Item; + const availableSpaceAfterRetrieval = inventory.getAvailableSpace(); + expect(retrievedItemB).toEqual(itemB); + expect(inventory.peekAt(0)).toEqual(itemA); + expect(inventory.peekAt(1)).toEqual(itemC); + expect(availableSpaceAfterRetrieval - availableSpaceBeforeRetrieval).toEqual(retrievedItemB.weight); + }); + + it("returns undefined when retrieving non-existing items, whether the inventory has items or not", () => { + expect(inventory.retrieve("foo")).toEqual(undefined); + expect(new Inventory().retrieve("foo")).toEqual(undefined); + }); + + it("returns undefined when peeking non-existing positions, whether the inventory has items or not", () => { + expect(inventory.retrieveAt(1000)).toEqual(undefined); + expect(new Inventory().retrieveAt(1000)).toEqual(undefined); + }); + + it("throws an error when retrieving by a less-than-zero index on a non-empty inventory", () => { + expect(() => inventory.retrieveAt(-1)).toThrow(/non-negative index/); + }); }); });