Design Docs - Thinking about Inventory - Part 5

The last few days have been pretty eventful. First, I finally wrapped up my responsibilities at Microsoft. I've been getting ready for a new move to be slightly closer to San Francisco for my next job. I went down to Monterey Bay to visit my favorite aquarium. The deep sea always fascinates me! I'm a sucker for really nice photos and videos of nature... I'm actually setting our SUV to be more camper-friendly so that we can actually take longer road-trips and finally check off exploring the entire US off the bucket list.

Finally, I started playing this game, as a way to try and my wife into video games. πŸ˜„

That's not to say I haven't been thinking about inventory. Afterall, this is what this post is about, right? We're supposed to be thinking about inventory! 🌽 The progress has been great and I'm definitely to excited share exactly what's been on my mind.

When I had begun thinking and writing about inventory, I had no idea that I would eventually want to design an inventory system to store procedurally generated items. In fact, the original inventory system I had envisioned was much closer to a very old school JRPG.

Scott convinced me otherwise that the new way to go is by the use of procedurally generated content (PGC). There are so many benefits to it such as extending the game's replayability, and keeping the player immersed in the virtual world through curiosity, and stimulation.

I remember playing Diablo years ago, and being quite invested in the game. One of the aspects of the gameplay that drew me into it was the procedurally generated content. It's been a while, but I can't remember whether it was Diablo, or Diablo 2 that started it all... But... The treasure within the game was dynamically generated that I always found myself hunting for more gear to outfit my character to be stronger.

Diablo Title Screen

This sort of mechanism built into its core gameplay caused me to burn a lot of time running the same dungeons over and over again. Great on Blizzard's part though because for myself, running through dungeons repeatedly never felt like grind-work simply because I was always on the edge of my seat wondering what type of item will be dropping next for me from a mob.

I even fell for the same trick years later when Diablo 3 came out. In fact, it was even worse as I spent a lot of my first year after college just grinding through the game. I should've been... like... having more of a social life, or something. πŸ˜“

Anyway, before I ramble your attention away, I'd like to communicate the objectives of this chapter up-front on what's going to be achieved by the end of this discussion.

  1. We're going to establish an algorithm which can generate items.
  2. We'll create a data model to be used to represent the information needed to generated the items.
  3. We'll implement the algorithm in code, and write appropriate tests which cover some realistic gaming scenarios.
  4. We will also integrate this new item generation component into our existing inventory system.
  5. Finally, we'll write a few tests that will simulate a couple of scenarios which touch upon the integrated code.

 

There is quite a lot of ground to cover, but I think we will make it through with a good heaping scoop of patience. ☺️ 🍺 (or beer)

The Story So Far

If you have just stumbled upon this post, or perhaps you are a new reader, you can read the previous "chapters" in Part 1, Part 2, Part 3, and Part 4 to gain some context and familiarity in what is being worked on. πŸ˜„

Here's what we'll have by the end of this journey today!

UML Inventory System

Full Diagram View

Getting the Code

Read here if you just want code!

For the lazy, impatient, or extremely curious, you can immediately browse to the code in which we will be writing together here: https://github.com/urbanspr1nter/jrpg-inventory-system

I've tagged to release as ch05, so it will be easier to look at changes that have been made since ch04.

Procedurally Generated Content

Prior to this post, I had absolutely no idea how to even begin implementing a system which could procedurally generate items. It didn't help that I had realized I haven't played many western RPGs which this sort of gameplay is very prominent. To be fair, I also haven't played many modern JRPGs either, and it could be that nowadays, everything is all procedurally generated! πŸ˜† Who knows... I guess I'm getting old.

Because of that, I had serious limitations when it came to designing a potential soution for generating items. Despite all this, I have and always been a Diablo fan. So, I figured that it would be at least helpful to look into some of the history of its development process as motivation. Of course, I also used this as a good excuse to just relax, and veg-out on video game history.

Here are some of the resources I had used to get into the mindset of designing an inventory system that is capable of procedurally generating items:

The best document I read to really inspire me, and which I found key to implementing an item generator was the Diablo 2 Item Generation Tutorial. Although a long document, it was immensely valuable in my journey.

Diablo 2 Generated Item

How Generating Items Work

As it turns out, items in Diablo 2 just don't come out of nowhere! A finite class of items can only be generated by a specific enemy. These list of items, called TreasureClass are within a property of the enemy structure. Within this list, a specific item class is chosen randomly in a weighted probabilistic fashion.

Once that item is chosen, the game algorithm traverses a data structure representing this item that ultimately leads to a final, base item with basic properties, or at any time during the traversal process decides not to generate any item at all with a "nodrop".

For the latter case, "nodrop" means exactly what it means. No items will be dropped by the enemy.

The other case is interesting specifically in that choosing an item from an enemy to generate doesn't specifically lead to a generated item right away. The process is much more complicated than that, but at the same time, simple to understand.

Since each treasure class in turn, can lead to other treasure classes, the algorithm traverses through by picking a child item class randomly until the item it lands on no longer has any additional item classes associated with it.

If the graph data structure immediately comes to mind after reading above, then you have definitely understood, or at least in agreement with me with regards to how it is all represented. πŸ˜„

So, the base item is a treasure class that doesn't have any children, and is essentially the terminus. This base item will have basic properties that defines the item's purpose. Does using, or equipping the item augment a stat? What effects occur, and how much change does the character undergo is what the information within the base item contains.

The Diablo 2 item generation then takes this base item and begins augmenting it by applying other interesting properties, and if needed, additional modifications to the base item by affixing randomly chosen prefixes, suffixes, prefixes and suffixes, or no affixing at all to the item.

Affixing the item alters it further, and causes the value, quality and other meta information relating to the item to change. Most noticeably, affixing alters the item name.

Claymore becomes:

  • Prefix-only:

    • Prismatic Claymore
  • Suffix-only

    • Claymore of the Apprentice
  • Prefix & Suffix

    • Prismatic Claymore of the Apprentice
  • No Affixing

    • Claymore

 

With the following approach, the game can generate almost a limitless variety of items off from a realtively small data set. That being said, I've not only summarized the item generation approach in which Diablo 2 uses, but also the general approach in how we will be implementing our own item generation feature for our inventory system! 😎

A Simple Scenario: Defeating a Slime

The best way to describe my approach is to illustrate with an example. Suppose we are in battle with the run-of-the-mill JRPG slime.

Dragon Quest Slime

As with all JRPG battles, defeating an enemy may reward you with any, or all of the following:

  • Experience points
  • Gold
  • Items

For now, we'll just assume our slime always gives us XP, and Gold.

The slime can drop different types of items in a weighted probabilistic manner:

  1. NoDrop - Most likely to occur
  2. Tonic - Heals 10 HP
  3. Sword 1 - A one-handed sword
  4. Dagger - A basic dagger

NoDrop stays true to the terminology used by Diablo, and means no item will be dropped.

Tonic will be dropped as the most common item if there is to be a drop. It's a basic item which heals 10 HP.

Now, we have Sword 1, and Dagger. These are generic class names for a one-handed sword and basic dagger respectively. Now, of course, these two classes will lead to subsequent classes with each being more specific. For now, let's keep it one level deep.

  • Sword 1

    • Bronze Sword
    • Iron Sword
    • Mithril Sword
    • Crystal Sword
    • Diamond Sword
    • NoDrop
  • Dagger

    • Dirk
    • Mithril Dagger
    • Gladius
    • NoDrop

Keep in mind that at any point, the item generation algorithm can decide that no items will be dropped.

We can then model the drops as a graph:

Slime Drop Graph

The algorithm will traverse through this graph until it encounters a vertex without any neighbors. This is the terminus and is the base item found.

Suppose the algorithm chooses the following path after we have defeated the slime:

Path to Gladius

The algorithm has decided to have a type of dagger, Gladius be dropped. Let's just say equipping this Gladius dagger boosts the character's ATK stat by 2.

Upon choosing the base item, we now want to make this item "unique" by affixing the item name with either a prefix, suffix, prefix and suffix, or none at all. All of these can occur at uniform, or weighted probability.

Suppose we have the following prefixes with the stat modifiers associated:

  • Deadly (ATK +2)
  • Cracked (ATK -1)
  • Superior (ATK +1)
  • Lucky (LUK +2)

We also have the following suffixes:

  • of Piercing (ATK +1)
  • of Performance (EVA +1)
  • of Regeneration (VIT +1)

The affixing process will take the associated stat modifications and stack it on top of our base item.

Here are some possibilities:

  • Deadly Gladius (ATK +4)
  • Cracked Gladius of Piercing (ATK +2)
  • Gladius of Piercing (ATK +3)
  • Lucky Gladius of Performance (ATK +2, LUK +2, EVA +1)
  • Superior Gladius (ATK +3)
  • Superior Gladius of Regeneration (ATK +3, VIT +1)

We can generate many combinations with this small data set already! Also, notice that we obtained some interesting combinations of daggers? A dagger like a Cracked Gladius of Piercing isn't very desirable as it only gives a standard ATK +2 boost. Daggers such as Lucky Gladius of Performance and Superior Gladius of Regeneration seem much more desirable as they provide a wide variety of statistical augmentation.

For the scope of our inventory system, this approach is good in that i tis simple, performant, and generates items which are interesting enough for the player to want to dungeon crawl, or go through random battles as with typical JRPGs.

So for now, let's stick with this approach.

Developing a Data Model

After going through multiple projects in both a personal, and professional manner, I'm convinced that developing a solid, consistent, and clean data model in the beginning of the development lifecycle makes for an easier time implementing the application down the line.

I'm not saying that must have a data model finalized, and in place before even writing code. Rather, I meant that having a stable schema definition, and relationships clearly defined between entities will aid in:

  1. Writing code that is immediately testable.
  2. Code that is inherently more readable due to a consistent system put in place for terminology as a result of a stable schema.
  3. Scenarios are more easily identifiable and visualized as a result of the data model being a manifestation of the conceptual model, rather than relying on the conceptual model to communicate to others in an abstract manner.

This list can go on and on, but the most important benefit I have found with developing a data model first has been in that I am able to identify architectural smells and inconsistencies with my designs much faster if I lay out a stable model in front of me as opposed to diving head-first and developing on-the-fly. I am not criticizing those who do this, just that the data-model first approach has worked well for myself.

All of this is just my personal opinion, but working on this project has convinced me even more that it is a good philosophy to follow.

Whether or not you're in agreement with my spiel on what I just said, we'll now take a stab (haha, get it, get it? πŸ₯) in developing a data model to represent the item generation component of our inventory system.

I'm sticking to a JSON schema for these examples as I have found JSON to be easily comprehendable due to the self-explanatory way schema is presented.

Monsters.json

All items for now will originate as a "drop" from a defeated enemy. For now, let's create a simple database to contain our enemies and the possible drops which can be generated. Monsters.json will hold all these entries.

A sample entry for Slime is shown:

  {
    "Name": "Slime",
    "Level": 1,
    "ItemClass": [
      {
        "Name": "NoDrop",
        "Weight": 10
      },
      {
        "Name": "Tonic",
        "Weight": 5
      },
      {
        "Name": "Sword 1",
        "Weight": 1
      },
      {
        "Name": "Dagger",
        "Weight": 1
      }
    ]
  }

Here, the term ItemClass will stick with the rest of this discussion. The term here is synonymous with Diablo's TreasureClass.

Name describes the monster's name. Level is currently a placeholder to aid in generating the statistics for the monster for when a battle system is implemented in the future.

Each entry in the monster's ItemClass property is not directly the name of the item class that can possibly be dropped, but an object representing an edge that is structured like this:

{
	"Name": the name of the item,
	"Weight": likelihood of being chosen
}

The Name field above is self-explanatory, but the Weight field is worth some discussion.

The Weight field serves as an indication on how likely the edge will be taken by the graph traversal algorithm when item generation is being performed.

In the Slime example, we find that NoDrop is 10 times more likely to occur than a one-handed sword Sword 1 , or a dagger Dagger. Tonic is 5 times more likely to occur than the latter two.

The ItemClass entries in the monster entry serves as a list of valid starting edges in which can be chosen to begin the traversal to find the base item to be used for item generation.

Items.json

Once the ItemClass of the drop from the monster is determined, the Name of the ItemClass is then used to search for the vertex within the Items database found in Items.json.

Suppose Dagger was chosen. The entry looks like:

  {
    "Name": "Dagger",
    "ItemClass": [
      {
        "Name": "NoDrop",
        "Weight": 15
      },
      {
        "Name": "Dirk",
        "Weight": 15
      },
      {
        "Name": "Mithril Dagger",
        "Weight": 10
      },
      {
        "Name": "Gladius",
        "Weight": 1
      }
    ],
    "Properties": [],
    "Value": 0,
    "BodyPart": "Arms"
  }

In a similar fashion as the entry in Monsters.json, let's take a look at each field found in a typical entry of Items.json.

  • Name describes the name of the item. This is the search key of the vertex which represents the object.
  • ItemClass uses the same object type found in the monster entry. It holds the valid edge which can be taken from this current vertex. A path to a "neighboring vertex" if you will.
  • Properties contains attributes which when the item is equipped, will augment the character's statistics. Since our current item just serves as a parent item class, it is no equippable, as it cannot be generated as a "possible" base item.
  • Value is the base cost of the item which can be used for purchasing and selling. Since this item cannot be generated as a base item, the value is 0.
  • BodyPart is the string representation of the the BodyPart enum which is read during item equipping/unequipping.

The algorithm will then inspect the ItemClass property to determine the next edge to reach. I'd like to point out that at this point, if the algorithm has decided to choose the edge that leads to a NoDrop:

  {
    "Name": "NoDrop",
    "ItemClass": [],
    "Properties": [],
    "Value": 0,
    "BodyPart": "Default"
  }

The algorithm should recognize the landing on this vertex specifically as a directive to not generate any item.

Now say for example, we reach a terminus immediately such as Tonic. The algorithm will immediately run into a base item as the ItemClass for the Tonic entry is empty:

  {
    "Name": "Tonic",
    "ItemClass": [],
    "Properties": [
      {
        "Name": "CURRHP",
        "Value": 10
      }
    ],
    "Value": 3,
    "BodyPart": "Default"
  }

The Tonic item is a good item to introduce what an entry in Properties looks like.

As stated before, a property entry designates the statistic belonging to the character that should be modified. In the case for Tonic, it has a single property that will augment the CURRHP statistic by 10. The default operation is Addition, but can be overridden by the Operation property within this entry. You will find usage of this when we discuss affixing. πŸ˜„

Now, suppose the edges NoDrop and Tonic were not chosen, and Gladius was chosen instead. The algorithm traverses to the vertext corresponding to Gladius.

  {
    "Name": "Gladius",
    "ItemClass": [],
    "Properties": [
      {
        "Name": "ATK",
        "Value": 4
      }
    ],
    "Value": 10,
    "BodyPart": "Arms"
  }

The empty ItemClass property denotes that this is a base item. The algorithm will use this as a part of the final item to be generated.

Generated Gladius

Affixing

The one thing that makes items extremely interesting is not only the stat augmentations which occur on the character, but also the colorful and novel verbiage that accompany the generated items. The psyschological effect in affixes being applied to the generated item provides novelty -- having Lucky Gladius of Performance over Gladius provides a more interesting experience. This is profound.

The name of an item can have the following applied:

  • Prefix
  • Suffix
  • Prefix and Suffix
  • None at all

Each affix will modify not only the name of the item, but will additionally apply new properties onto the item, or modify existing ones.

Prefixes and suffixes share the same schema, but as I was inspired by the Diablo's approach to affixing, I have kept them separate in a Prefixes.json and Suffixes.json file.

A typical entry in Prefixes.json looks like:

  {
    "Name": "Lucky",
    "ParentItemClass": [ "Sword 1", "Dagger" ],
    "Weight": 1,
    "Properties": [
      {
        "Name": "ATK",
        "Operation": "Multiply",
        "Value": 1.15
      },
      {
        "Name": "LUK",
        "Value": 2
      }
    ],
    "Value": {
      "Operation": "Multiply",
      "Value": 1.15
    }
  }

At this point, the properties should all be familiar now, except for the ParentItemClass property. THis property actually indicates which type of ItemClass the current prefix, or suffix is actually valid for.

In the xample, the prefix Lucky is valid for both Sword 1 and Dagger. Since Gladius derives from Dagger , this prefix can be applied.

Generated Lucky Gladius

Note that although the Lucky prefix specificed a 15% multiplier on top of the existing Gladius ATK stat, 4 * 1.15 = 4.6, we had rounded up to 5 to get this number.

Since the suffix is the same schema, let's just arbitrarily pick a suffix:

  {
    "Name": "of Performance",
    "ParentItemClass": [ "Sword 1", "Dagger" ],
    "Weight": 1,
    "Properties": [
      {
        "Name": "ATK",
        "Operation": "Multiply",
        "Value": 1.15
      },
      {
        "Name": "EVA",
        "Value": 2
      }
    ],
    "Value": {
      "Operation": "Multiply",
      "Value": 1.15
    }
  }

Our item finally becomes Lucky Gladius of Performance

Generated Lucky Gladius of Performance

Comparing the two weapons:

Generated Gladius

Generated Lucky Gladius of Performance

Which would you rather have? πŸ€

The algorithm also handles cases in which NoPrefix and NoSuffix are chosen. These indicate that no affixing should occur. This is how we are still able to generate items such affixes such as Gladius, or even Tonic.

Weighted Probability

Before diving further into thinking about implementation, let's take a moment to discuss how we can implement the weighted probability we have been using in our graph traversal algorithm.

Normally, we are used to using uniform probability when generating random numbers. For our purposes, where some items are likely to be picked over others, we need to use weighted distribution. I have implemented the algorithm to do the approach mentioned:

  1. Given a list of items with numeric weights, obtain the total sum of weights of all items in this list, S.
  2. Generate a random number between [1, S] , call it R.
  3. Let the current sum P, be 0, and iterate through the list again. For each item, take its current weight N and add it to P.
  4. If P is greater than, or equal to R, then this is the item to be picked.

This looks like the following in code:

public static ItemClassEdge GetRandomItemEdge(List<ItemClassEdge> edges)
{
  int S = GetSumOfItemClassEdges(edges);
  int R = GetRandomNumber(1, S);

  int P = 0;
  foreach(ItemClassEdge edge in edges)
  {
    P += edge.Weight;

    if (P >= R)
    {
    	return edge;
    }
  }

  return edges[edges.Count - 1];
}

This little method will be useful for the entire implementation!

Traversal Algorithm

By now, it should be pretty clear that our traversal algorithm can be implemented as a recursive DFS graph traversal. The basic approach:

  1. Obtain the starting vertex by randomly choosing an edge found in the ItemClass property in the monster entry.
  2. Given the Name of the ItemClass, search for the entry in the items database.
  3. If the item is a base item, meaning it does not contain any edges to other item classes (ItemClass property is empty) -- return this as a base item.
  4. Otherwise, choose the next edge to the next item class found in the ItemClass property in a weighted randomized fashion.
  5. Recursively search with this item class until there ar enomore edges to consider.

 

Once we have obtained the item, affixing will be performed by obtaining all prefixes and suffixes which are valid for the base item's initial item class (the item class belonging to the starting edge).

In pseudo-code, the process can be described as:

  1. Obtain the prefix valid for the item.
  2. Obtain the suffix valid for the item.
  3. If a prefix was acquired (Name property is not NoPrefix), then apply the prefix onto the base item name. Also apply stat modifiers to the base item provided by the prefix.
  4. Follow the same process for the suffix.

 

I'd like to point out that applying hte prefix and suffix is stackd. That means if a prefix had been applied to the item, applying a suffix would be apply it onto the result of the item after the prefix had been applied.

Writing Code

This has been a lot of information so far. This section will now transition to implementing all of this procedural item generation stuff into code. Have your IDE ready, and... let's begin!

Our implementation requires 4 files to serve as "databases":

 

Clicking on each of the above links will take you to the appropriate file found in source control.

In the InventorySystem project, you will need to create a new folder called Resources. Drop the JSON files into this folder.

Resources folder created

since we are dealing with JSON, we will also want to add a new library to the project, Newtonsoft.Json.

We'll also need to write some POCO type classes to represent the objects found in our JSON schema. Create a new folder called PgItems within the InventorySystem project. The class files will reside within that directory for now.

The following classes are enough to represent our current schema. I have also chosen to override Equals where appropriate on some of these plain objects. You will see usage of them in later sections where I will demonstrate a couple of scenarios through unit tests.

Item.cs
using System.Linq;
using System.Collections.Generic;
using System;

namespace InventorySystem.PgItems
{
    public class Item
    {
        public string Name { get; set; }
        public List<ItemClassEdge> ItemClass { get; set; }
        public List<Property> Properties { get; set; }
        public double Value { get; set; }
        public string BodyPart { get; set; }

        public override bool Equals(object obj)
        {
            var that = obj as Item;

            if(that == null)
            {
                return false;
            }

            if (!that.Name.Equals(Name))
            {
                return false;
            }
            if (!that.Value.Equals(Value))
            {
                return false;
            }
            if (!that.BodyPart.Equals(BodyPart))
            {
                return false;
            }
            if (!that.ItemClass.SequenceEqual(ItemClass))
            {
                return false;
            }
            if (!that.Properties.SequenceEqual(Properties))
            {
                return false;
            }

            return true;
        }

        public override int GetHashCode()
        {
            return HashCode.Combine(Name, ItemClass, Properties, Value, BodyPart);
        }
    }
}
ItemClassEdge.cs
using System;
namespace InventorySystem.PgItems
{
    public class ItemClassEdge
    {
        public string Name { get; set; }
        public int Weight { get; set; }

        public override bool Equals(object obj)
        {
            var that = (ItemClassEdge)obj;
            return that.Name.Equals(Name) && that.Weight == Weight;
        }

        public override int GetHashCode()
        {
            return HashCode.Combine(Name, Weight);
        }
    }
}
Monster.cs
using System.Collections.Generic;
namespace InventorySystem.PgItems
{
    public class Monster
    {
        public string Name { get; set; }
        public int Level { get; set; }
        public List<ItemClassEdge> ItemClass { get; set; }
    }
}
Property.cs
using System;
namespace InventorySystem.PgItems
{
    public class Property
    {
        public static class OperationLabel
        {
            public static string Addition = "Addition";
            public static string Multiply = "Multiply";
        }

        public string Name { get; set; }
        public string Operation { get; set; }
        public double Value { get; set; }

        public override bool Equals(object obj)
        {
            var that = (Property)obj;

            if(!that.Name.Equals(Name))
            {
                return false;
            }
            if(that.Operation != null && Operation != null
                && !that.Operation.Equals(Operation))
            {
                return false;
            }
            if(that.Operation == null && Operation != null
                || that.Operation != null && Operation == null)
            {
                return false;
            }
            if(!that.Value.Equals(Value))
            {
                return false;
            }

            return true;
        }

        public override int GetHashCode()
        {
            return HashCode.Combine(Name, Operation, Value);
        }
    }
}
ValueObject.cs
using System;
namespace InventorySystem.PgItems
{
    public class ValueObject
    {
        public string Operation { get; set; }
        public double Value { get; set; }

        public override bool Equals(object obj)
        {
            var that = (ValueObject)obj;

            if(that.Operation != null && Operation != null
                && !that.Operation.Equals(Operation))
            {
                return false;
            }
            if((that.Operation != null && Operation == null) ||
                (that.Operation == null && Operation != null))
            {
                return false;
            }
            if(!that.Value.Equals(Value))
            {
                return false;
            }

            return true;
        }

        public override int GetHashCode()
        {
            return HashCode.Combine(Operation, Value);
        }
    }
}

In order to populate an in-memory state of the JSON files, we will need to write a small and simple deserializer utility to load the JSON data into memory represented by the POCO objects.

Create a directory called Utils/DbReader. This is the folder where we'll place an interface which is required to generically handle deserializing the JSON files. Create a file called IDbReader.cs. This will be the basic interface which will be needing implementation for the different type of readers we will be creating.

IDbReader.cs
using System.Collections.Generic;
namespace InventorySystem.Utils.DbReader
{
    public interface IDbReader
    {
        public List<T> ReadData<T>(string filename);
    }
}

For now, it is super simple. It only requires the ReadData<T> method to be implemented.

While we are at it, let's create some constants which refer to our JSON files in the file DbConstants.cs.

DbConstants.cs
namespace InventorySystem.Utils.DbReader
{
    public class DbConstants
    {
        public static string MonstersDbFile = "Resources/Monsters.json";
        public static string ItemsDbFile = "Resources/Items.json";
        public static string PrefixesDbFile = "Resources/Prefixes.json";
        public static string SuffixesDbFile = "Resources/Suffixes.json";
    }
}

Now, we won't have to remember the path to our JSON files. These should all be in some config file though, but for the sake of demo, it works.

Let's now provide custom implementations for all the JSON files by implementing IDbReader. These files aren't really special right now. They just read in the text content of the JSON files and then deserializes them into memory the list of the corresponding objects we had created earlier. (e.g., ItemsDbReader reads Items.json into a List<item>).

MonstersDbReader.cs
using System.Collections.Generic;
using System.IO;

namespace InventorySystem.Utils.DbReader
{
    public class MonstersDbReader : IDbReader
    {
        public List<Monsters> ReadData<Monsters>(string filename)
        {
            var contents = File.ReadAllText($"{filename}");
            var data = Newtonsoft.Json.JsonConvert.DeserializeObject<List<Monsters>>(contents);

            return data;
        }
    }
}
ItemsDbReader.cs
using System.IO;
using System.Collections.Generic;

namespace InventorySystem.Utils.DbReader
{
    public class ItemsDbReader : IDbReader
    {
        public List<Item> ReadData<Item>(string filename)
        {
            var contents = File.ReadAllText($"{filename}");
            var data = Newtonsoft.Json.JsonConvert.DeserializeObject<List<Item>>(contents);

            return data;
        }
    }
}
PrefixDbReader.cs
using System.Collections.Generic;
using System.IO;

namespace InventorySystem.Utils.DbReader
{
    public class PrefixDbReader : IDbReader
    {
        public List<Affix> ReadData<Affix>(string filename)
        {
            var contents = File.ReadAllText($"{filename}");
            var data = Newtonsoft.Json.JsonConvert.DeserializeObject<List<Affix>>(contents);

            return data;
        }
    }
}
SuffixDbReader.cs
using System.Collections.Generic;
using System.IO;

namespace InventorySystem.Utils.DbReader
{
    public class SuffixDbReader : IDbReader
    {
        public List<Affix> ReadData<Affix>(string filename)
        {
            var contents = File.ReadAllText($"{filename}");
            var data = Newtonsoft.Json.JsonConvert.DeserializeObject<List<Affix>>(contents);

            return data;
        }
    }
}

 

So, a typical usage of a DbReader can be for example, reading in the Prefixes.json file:

var prefixesDbReader = new PrefixDbReader();
var prefixes = prefixesDbReader.ReadData<Affix>(DbConstants.PrefixesDbFile);

prefixes will then be a List<Affix>, and can leverage System.Linq to do some basic querying over the data set.

 

Finally, we'll finish off the Utils directory with a file called RandomUtil.cs that houses the algorithms needed to perform some of the weighted probability stuff.

Now, we'll implement a few utility methods to obtain randomized ItemClassEdge and Affix in a weighted probabilistic manner. I've decided for the sake of simplicity, to create an individual method for each obtaining an ItemClass edge, Prefix, and Suffix randomly.

To get a random Prefix the static method GetRandomAffix(List<Affix>) can be used:

var prefix = RandomUtil.GetRandomAffix(prefixes);

You will also notice that GetRandomItemEdge and GetRandomAffix both use the same approach to determine which random entity to pick. We'll work on making this code less "repeated" later.

RandomUtil.cs
using System.Collections.Generic;
using System;
using InventorySystem.PgItems;

namespace InventorySystem.Utils
{
    public class RandomUtil
    {
        private static int GetSumOfItemClassEdges(List<ItemClassEdge> edges)
        {
            var sum = 0;
            edges.ForEach(edge => sum += edge.Weight);
            return sum;
        }

        private static int GetSumOfAffixEdges(List<Affix> edges)
        {
            var sum = 0;
            edges.ForEach(edge => sum += edge.Weight);
            return sum;
        }

        private static int GetRandomNumber(int min, int max)
        {
            return new Random().Next(min, max + 1);
        }

        public static ItemClassEdge GetRandomItemEdge(List<ItemClassEdge> edges)
        {
            int S = GetSumOfItemClassEdges(edges);
            int R = GetRandomNumber(1, S);

            int P = 0;
            foreach(ItemClassEdge edge in edges)
            {
                P += edge.Weight;

                if (P >= R)
                {
                    return edge;
                }
            }

            return edges[edges.Count - 1];
        }

        public static Affix GetRandomAffix(List<Affix> affixes)
        {
            int S = GetSumOfAffixEdges(affixes);
            int R = GetRandomNumber(1, S);

            int P = 0;
            foreach(Affix affix in affixes)
            {
                P += affix.Weight;

                if (P >= R)
                {
                    return affix;
                }
            }
            return affixes[affixes.Count - 1];
        }
    }
}

Keep in mind that all this can be written better, or more elegantly, but I feel like keeping things simple and understandable results in some code duplication. Bear with me on that!

Utils folder

Generating Items with ItemGenerator

Yeesh! We've had so much set up code, but now it's time for the FUN part of the project. Actually generating the items! This section is also pretty long, but worthwhile as we'll start to finally see a scenario similar to a Diablo/Diablo 2 drop occur.

Fun, fun, fun!

This section will first start out with implementing an item generator class called ItemGenerator to:

  • Generate a base item given a list of valid drops from a monster.
  • Apply prefixes and suffixes as needed to the item
  • Apply the properties which the prefixes and suffixes provide to augment the statistics of the item.
  • Finally produce an item which can readily be used, or equipped by an active party member in a JRPG.

 

ItemGenerator.cs
using System;
using System.Collections.Generic;
using InventorySystem.Utils;
namespace InventorySystem.PgItems
{
    public class ItemGenerator
    {
        private enum AffixType
        {
            Prefix,
            Suffix
        }

        private List<Item> items;
        private List<Affix> prefixes;
        private List<Affix> suffixes;

        public ItemGenerator(List<Item> items, List<Affix> prefixes, List<Affix> suffixes)
        {
            this.items = items;
            this.prefixes = prefixes;
            this.suffixes = suffixes;
        }

        private Item FindBaseItem(ItemClassEdge edge)
        {
            var item = items.Find(i => i.Name.Equals(edge.Name));
            var itemEdges = item.ItemClass;

            if(itemEdges.Count == 0)
            {
                return new Item()
                {
                    Name = item.Name,
                    ItemClass = new List<ItemClassEdge>(item.ItemClass),
                    Properties = new List<Property>(item.Properties),
                    Value = item.Value,
                    BodyPart = item.BodyPart
                };
            }

            var randomItemEdge = RandomUtil.GetRandomItemEdge(itemEdges);

            return FindBaseItem(randomItemEdge);
        }

        private Affix GetRandomAffix(string initialItemClassName, AffixType affixType)
        {
            var validAffixes = new List<Affix>();

            if(affixType == AffixType.Prefix)
            {
                validAffixes = new List<Affix>(prefixes);
            } else if(affixType == AffixType.Suffix)
            {
                validAffixes = new List<Affix>(suffixes);
            }

            validAffixes.RemoveAll(p => !p.ParentItemClass.Contains(initialItemClassName));

            if(validAffixes.Count == 0)
            {
                return affixType == AffixType.Prefix
                    ? prefixes.Find(x => x.Name.Equals(Affix.AffixLabel.NoPrefix))
                    : suffixes.Find(x => x.Name.Equals(Affix.AffixLabel.NoSuffix));
            }

            return RandomUtil.GetRandomAffix(validAffixes);
        }

        private void ApplyProperties(Item item, Affix prefix, Affix suffix)
        {
            foreach(Property p in prefix.Properties)
            {
                var currItemProperty = item.Properties.Find(prop => prop.Name.Equals(p.Name));
                if (currItemProperty != null)
                {
                    if (p.Operation != null
                        && p.Operation.Equals(Property.OperationLabel.Multiply))
                    {
                        currItemProperty.Value *= p.Value;
                    }
                    else
                    {
                        currItemProperty.Value += p.Value;
                    }

                    currItemProperty.Value = Math.Round(currItemProperty.Value);
                }
                else
                {
                    item.Properties.Add(new Property() {
                        Name = p.Name,
                        Value = p.Value
                    });
                }
            }

            foreach (Property p in suffix.Properties)
            {
                var currItemProperty = item.Properties.Find(prop => prop.Name.Equals(p.Name));
                if (currItemProperty != null)
                {
                    if (p.Operation != null
                        && p.Operation.Equals(Property.OperationLabel.Multiply))
                    {
                        currItemProperty.Value *= p.Value;
                    }
                    else
                    {
                        currItemProperty.Value += p.Value;
                    }

                    currItemProperty.Value = Math.Round(currItemProperty.Value);
                }
                else
                {
                    item.Properties.Add(new Property() {
                        Name = p.Name,
                        Value = p.Value
                    });
                }
            }
        }

        public Item GenerateItem(Monster monster)
        {
            var monsterDrops = monster.ItemClass;
            var startItemEdge = RandomUtil.GetRandomItemEdge(monsterDrops);

            var result = FindBaseItem(startItemEdge);
            if(!result.Name.Equals("NoDrop"))
            {
                var prefix = GetRandomAffix(startItemEdge.Name, AffixType.Prefix);
                var suffix = GetRandomAffix(startItemEdge.Name, AffixType.Suffix);

                if (!prefix.Name.Equals(Affix.AffixLabel.NoPrefix))
                {
                    result.Name = $"{prefix.Name} {result.Name}";
                }
                if (!suffix.Name.Equals(Affix.AffixLabel.NoSuffix))
                {
                    result.Name = $"{result.Name} {suffix.Name}";
                }

                ApplyProperties(result, prefix, suffix);
            }

            return result;
        }
    }
}

Lots to go through here, but let's take it a step at a time.

First, to keep things flexible, ItemGenerator needs to be instantiated with 3 parameters:

  1. List of items read in from Items.json
  2. List of prefixes read in from Prefixes.json
  3. List of suffixes read in from Suffixes.json

The constructor will take these in and assign it to the instance variables:

private List<Item> items;
private List<Affix> prefixes;
private List<Affix> suffixes;

There is only a single entry point for any clients wishing to use ItemGenerator. This method is called GenerateItem (conveniently enough!) and uses a Monster as the input parameter to determine the drop.

The list of valid item class edges are gathered from the input monster's ItemClass property. Then, the base item is randomly chosen from this list of edges with the FindBaseItem method. This method is a recursive traversal algorithm:

private Item FindBaseItem(ItemClassEdge edge)
{
  var item = items.Find(i => i.Name.Equals(edge.Name));
  var itemEdges = item.ItemClass;

  if(itemEdges.Count == 0)
  {
    return new Item()
    {
      Name = item.Name,
      ItemClass = new List<ItemClassEdge>(item.ItemClass),
      Properties = new List<Property>(item.Properties),
      Value = item.Value,
      BodyPart = item.BodyPart
    };
  }

  var randomItemEdge = RandomUtil.GetRandomItemEdge(itemEdges);

  return FindBaseItem(randomItemEdge);
}

The input parameter is an ItemClassEdge. The first action which the traversal iteration takes it to use the current edge, and find the corresponding item in the "database".

Once that is found, this item's own list of edges is inspected. If there are no ItemClassEdge objects to consider, then this is the base item that should be returned. We actually return a new copy of the item here because this object will be modified, and our intention is not to disturbed the master item found within the Items database.

In other words, think of FindBaseItem as a method to create a copy of a key. The object found within the items database is the master key. We will modify the copy when affixing and applying properties onto the item being generated.

If the found item has edges to consider, then continue to find a random ItemClassEdge and use that as the input for the next iteration of the traversal. We recursively call FindBaseItem until we have hit the base case.

Upon finding a base item, we will then begin processing it to affix the appropriate prefixes and suffixes to create a novel and unique item. However, we do not do this if the base item turned out to be a NoDrop.

The GetRandomAffix method combines both getting a random prefix and suffix given the AffixType parameter passed in. Here, nothing too special is done. Basically a list of valid affixes are generated to filter out any affixes with their parent class name which do not exist in the ParentItemClass property of a specific affix. If there are no valid affixes, then either NoPrefix, or NoAffix is returned.

private Affix GetRandomAffix(string initialItemClassName, AffixType affixType)
{
  var validAffixes = new List<Affix>();

  if(affixType == AffixType.Prefix)
  {
  	validAffixes = new List<Affix>(prefixes);
  } else if(affixType == AffixType.Suffix)
  {
  	validAffixes = new List<Affix>(suffixes);
  }

  validAffixes.RemoveAll(p => !p.ParentItemClass.Contains(initialItemClassName));

  if(validAffixes.Count == 0)
  {
    return affixType == AffixType.Prefix
    	? prefixes.Find(x => x.Name.Equals(Affix.AffixLabel.NoPrefix))
    	: suffixes.Find(x => x.Name.Equals(Affix.AffixLabel.NoSuffix));
  }

  return RandomUtil.GetRandomAffix(validAffixes);
}

 

After affixing, ApplyProperties is called to compute the new statistics of the item. This method takes in the generated base item, the prefix and suffix and computes the new properties. This method also takes into consideration whether an operation is a Multiply, or Addition. For now, I've left the default operation to be Addition and have only written code to multiply when the Operation property is explicitly defined for it.

By the end of it all, GenerateItem will give either a novel item, or no drop at all. It is up to the game logic to determine how to use it now. Thankfully, we've already written most of that code in our previous parts!

Integration

We've added a lot of new components into our JRPG inventory system. If you have a Type A-ish personality like me, then you're probably freaking out right about now when it comes to thinking about how we're going to integrate all this stuff into our existing system!

Holy crap!

Well, here is some reassurance, it won't be that bad! Why? Because I've already done it for you. 😊 I'm a nice guy, right?

Items

Deleting useless code is probably the first thing we should do. Since we are no longer using hard-coded items, I think we're free to delete the following files from our project... they're useless now! Hah!

  • Items/IronHelmet.cs
  • Items/LeatherHelmet.cs
  • Items/Potion.cs

Now, it's time to give IItem.cs a new home. Let's put it where it rightfully belongs in at Items.

And finally, BaseItem is no longer an abstract class with the expectation that it should be extended. In fact, now, with this new implementation in takeing procedurally generated items into consideration, BaseItem has completely been rewritten!

The class still implements IItem, but now it provides its own implementations instead of defining them as abstract.

BaseItem.cs
using System;
using InventorySystem.PgItems;

namespace InventorySystem.Items
{
    public class BaseItem : IItem
    {
        public int Value
        {
            get
            {
                return Convert.ToInt32(ItemClassDefinition.Value);
            }
        }

        public string Name
        {
            get
            {
                return ItemClassDefinition.Name;
            }
        }

        public BodyPart BodyPart
        {
            get {

                return ToBodyPart(ItemClassDefinition.BodyPart);
            }
        }

        public Item ItemClassDefinition { get; }

        public BaseItem(Item baseItemDef)
        {
            ItemClassDefinition = baseItemDef;
        }

        private BodyPart ToBodyPart(string bodyPartName)
        {
            if(bodyPartName.Equals("Default"))
            {
                return BodyPart.Default;
            } else if(bodyPartName.Equals("Arms"))
            {
                return BodyPart.Arms;
            } else if(bodyPartName.Equals("Head"))
            {
                return BodyPart.Head;
            }

            return BodyPart.Default;
        }

        private void ApplyCharacterProperty(Character targetChar, Property statProp)
        {
            var value = Convert.ToInt32(statProp.Value);
            var operation = statProp.Operation;

            if(operation == null)
            {
                operation = Property.OperationLabel.Addition;
            }

            if (statProp.Name.Equals(CharacterStatistics.LABEL_CURR_HP))
            {
                if (operation.Equals(Property.OperationLabel.Multiply))
                {
                    targetChar.Statistics.CURR_HP *= value;
                }
                else
                {
                    targetChar.Statistics.CURR_HP += value;
                }
            }
            else if (statProp.Name.Equals(CharacterStatistics.LABEL_ATK))
            {
                if (operation.Equals(Property.OperationLabel.Multiply))
                {
                    targetChar.Statistics.ATK *= value;
                }
                else
                {
                    targetChar.Statistics.ATK += value;
                }
            }
            else if (statProp.Name.Equals(CharacterStatistics.LABEL_DEF))
            {
                if (operation.Equals(Property.OperationLabel.Multiply))
                {
                    targetChar.Statistics.DEF *= value;
                }
                else
                {
                    targetChar.Statistics.DEF += value;
                }
            }
            else if (statProp.Name.Equals(CharacterStatistics.LABEL_MAG))
            {
                if (operation.Equals(Property.OperationLabel.Multiply))
                {
                    targetChar.Statistics.MAG *= value;
                }
                else
                {
                    targetChar.Statistics.MAG += value;
                }
            }
            else if (statProp.Name.Equals(CharacterStatistics.LABEL_VIT))
            {
                if (operation.Equals(Property.OperationLabel.Multiply))
                {
                    targetChar.Statistics.VIT *= value;
                }
                else
                {
                    targetChar.Statistics.VIT += value;
                }
            }
            else if (statProp.Name.Equals(CharacterStatistics.LABEL_LUK))
            {
                if (operation.Equals(Property.OperationLabel.Multiply))
                {
                    targetChar.Statistics.LUK *= value;
                }
                else
                {
                    targetChar.Statistics.LUK += value;
                }
            }
            else if(statProp.Name.Equals(CharacterStatistics.LABEL_SPR))
            {
                if (operation.Equals(Property.OperationLabel.Multiply))
                {
                    targetChar.Statistics.SPR *= value;
                }
                else
                {
                    targetChar.Statistics.SPR += value;
                }
            }
        }

        private void UndoApplyCharacterProperty(Character targetChar, Property statProp)
        {
            var value = Convert.ToInt32(statProp.Value);
            var operation = statProp.Operation;

            if (operation == null)
            {
                operation = Property.OperationLabel.Addition;
            }

            if (statProp.Name.Equals(CharacterStatistics.LABEL_CURR_HP))
            {
                if (operation.Equals(Property.OperationLabel.Multiply))
                {
                    targetChar.Statistics.CURR_HP /= value;
                }
                else
                {
                    targetChar.Statistics.CURR_HP -= value;
                }
            }
            else if (statProp.Name.Equals(CharacterStatistics.LABEL_ATK))
            {
                if (operation.Equals(Property.OperationLabel.Multiply))
                {
                    targetChar.Statistics.ATK /= value;
                }
                else
                {
                    targetChar.Statistics.ATK -= value;
                }
            }
            else if (statProp.Name.Equals(CharacterStatistics.LABEL_DEF))
            {
                if (operation.Equals(Property.OperationLabel.Multiply))
                {
                    targetChar.Statistics.DEF /= value;
                }
                else
                {
                    targetChar.Statistics.DEF -= value;
                }
            }
            else if (statProp.Name.Equals(CharacterStatistics.LABEL_MAG))
            {
                if (operation.Equals(Property.OperationLabel.Multiply))
                {
                    targetChar.Statistics.MAG /= value;
                }
                else
                {
                    targetChar.Statistics.MAG -= value;
                }
            }
            else if (statProp.Name.Equals(CharacterStatistics.LABEL_VIT))
            {
                if (operation.Equals(Property.OperationLabel.Multiply))
                {
                    targetChar.Statistics.VIT /= value;
                }
                else
                {
                    targetChar.Statistics.VIT -= value;
                }
            }
            else if (statProp.Name.Equals(CharacterStatistics.LABEL_LUK))
            {
                if (operation.Equals(Property.OperationLabel.Multiply))
                {
                    targetChar.Statistics.LUK /= value;
                }
                else
                {
                    targetChar.Statistics.LUK -= value;
                }
            }
            else if (statProp.Name.Equals(CharacterStatistics.LABEL_SPR))
            {
                if (operation.Equals(Property.OperationLabel.Multiply))
                {
                    targetChar.Statistics.SPR /= value;
                }
                else
                {
                    targetChar.Statistics.SPR -= value;
                }
            }
        }

        public bool Apply(Character targetChar)
        {
            ItemClassDefinition.Properties
                .ForEach(p => ApplyCharacterProperty(targetChar, p));

            return true;
        }

        public bool CanApply(Character targetChar)
        {
            return true;
        }

        public bool UndoApply(Character targetChar)
        {
            ItemClassDefinition.Properties
                .ForEach(p => UndoApplyCharacterProperty(targetChar, p));

            return true;
        }
    }
}

Let's first walk through the most important change here. BaseItem now takes in an Item entry found within Items.json. This will serve as the ItemClassDefinition that will be referenced internally within the current BaseItem instance. Provided are handy getter methods that return the specific item's Value, Name and BodyPart. As you can see, these are properties which will be useful to the InventoryManager.

The Apply and UndoApply methods now serve as wrapper calls to the internal ApplyCharacterProperty and UndoApplyCharacterProperty methods respectively. These methods will iterate through the item's Properties field and serially augment the target character's statistics based on what statistics, operation and value the current property of that item has defined.

For Multiply operations, undoing will be a divide. For Addition, it will be a subtraction.

Once that's done, the Items directory should be pretty bare in comparison to before.

Items directory contents

 

Statistics Refactor

CharacterStatistics.cs just needs a quick addition to add in some label references and stat name modifications:

CharacterStatistics.cs
namespace InventorySystem
{
    public class CharacterStatistics
    {
        public static string LABEL_CURR_HP = "CURRHP";
        public static string LABEL_MAX_HP = "MAXHP";
        public static string LABEL_CURR_MP = "CURRMP";
        public static string LABEL_MAX_MP = "MAXMP";
        public static string LABEL_LVL = "LVL";
        public static string LABEL_EXP = "EXP";
        public static string LABEL_ATK = "ATK";
        public static string LABEL_MAG = "MAG";
        public static string LABEL_DEF = "DEF";
        public static string LABEL_SPR = "SPR";
        public static string LABEL_LUK = "LUK";
        public static string LABEL_VIT = "VIT";

        public int CURR_HP { get; set; }
        public int MAX_HP { get; set; }
        public int CURR_MP { get; set; }
        public int MAX_MP { get; set; }
        public int LVL { get; set; }
        public int EXP { get; set; }
        public int ATK { get; set; }
        public int MAG { get; set; }
        public int DEF { get; set; }
        public int SPR { get; set; }
        public int LUK { get; set; }
        public int VIT { get; set; }
    }
}

ItemName Refactor

Previously, we had relied on an enum for ItemName to represent our items as keys to the InventoryManager. That will no longer work here. Instead, let's just turn it into an object, and have it hold a string property called Name.

ItemName.cs
namespace InventorySystem
{
    public class ItemName
    {
        public string Name { get; }

        public ItemName(string name)
        {
            Name = name;
        }

        public override string ToString()
        {
            return Name;
        }
    }
}

Is that a refactor, or a rewrite? You be the judge.

Party Fixes

Given that we have now altered the ItemName.cs file to be instantiable, we have for sure broke 2 other classes in the process. Party.cs, and InventoryManager.cs. Let's focus on Party.cs.

First off, QueryFor previously used == to get the equality of the ItemName as it was an enum. Now, since it's a string object, we need to now use Equals for comparison:

public ItemInfo QueryFor(ItemName name)
{
  List<ItemInfo> data = inventoryManager.QueryAll();
  ItemInfo info = data.Find(x => x.Name.ToString().Equals(name.ToString()));

  return info;
}

Hey, a small, easy change. That's not too bad.

The next change requires more of a paradigmn shift in how items are acquired. Instead of using ItemName types to manage receiving an item, the ReceiveItem method will now take in a BaseItem type. We will also need to make this same change in InventoryManager.

public void ReceiveItem(BaseItem item, ItemReceiveAction action)
{
  switch(action)
  {
    case ItemReceiveAction.Purchase:
    	wallet -= item.Value;
    	inventoryManager.Acquire(item);
    	break;
    default:
    	inventoryManager.Acquire(item);
    	break;
  }
}

 

InventoryManager Fixes

This is a big one. Lots of changes here, but mostly in how the internal registry is now being represented. In order to maintain compatibility, the registry member within the inventory manager should now just be represented as a Dictionary<string, ItemData> collection, rather than <ItemName, ItemData>.

InventoryManager will perform the conversion internally to go from ItemName to a string by calling the ToString method of the ItemName object.

So most of the fixes here are just for that.

The second biggest thing is that we are no longer relying on an "initial registry" to prepopulate the InventoryManager. Instead the registry can start out empty, and we can provide the initial database of items found within Items.json. From here, then Acquire is called with the item that was generated and can be registered within the registry.

 

InventoryManager.cs
using System.Collections.Generic;
using InventorySystem.Items;

namespace InventorySystem
{
    public class InventoryManager
    {
        private Dictionary<string, ItemData> registry;

        public InventoryManager()
        {
            registry = new Dictionary<string, ItemData>();
        }

        public List<ItemInfo> QueryAll()
        {
            List<ItemInfo> data = new List<ItemInfo>();

            foreach(var name in registry.Keys)
            {
                data.Add(new ItemInfo
                {
                    Quantity = registry[name].Quantity,
                    Name = new ItemName(name),
                    Value = registry[name].Item.Value,
                    Description = name
                });
            }

            return data;
        }

        public bool Use(ItemName name, Character targetChar)
        {
            var key = name.ToString();

            if (registry[key].Quantity <= 0)
            {
                return false;
            }

            var item = registry[key].Item;

            if(!item.CanApply(targetChar))
            {
                return false;
            }

            item.Apply(targetChar);

            registry[key].Quantity--;

            return true;
        }

        public bool Drop(ItemName name)
        {
            var key = name.ToString();

            if (registry[key].Quantity <= 0)
            {
                return false;
            }

            registry[key].Quantity--;
            return true;
        }

        public bool Restore(ItemName name, Character targetChar)
        {
            var key = name.ToString();

            var item = registry[key].Item;
            registry[key].Quantity++;

            return item.UndoApply(targetChar);
        }

        public bool Acquire(BaseItem baseItem)
        {
            var key = baseItem.Name;

            if(!registry.ContainsKey(key))
            {
                registry.Add(key, new ItemData {
                    Quantity = 0,
                    Item = baseItem
                });
            }

            registry[key].Quantity++;

            return true;
        }

        public bool HasItem(ItemName name)
        {
            var key = name.ToString();

            return registry.ContainsKey(key) &&
                registry[key].Quantity > 0;
        }
    }
}

I was extremely mindful about all these changes and tried my best to maintain a clean codebase. Of course, there are skeletons here and there...

Skeletons cleaning

Testing!

Yes! It's what you have been waiting for! Now, we actually get a chance to see item generation in action! But first, let's refactor our tests. They have yet again, broke!

This section will be divided into 3 sub-sections. I'll first go walk through a few new files needed for our tests. They're basically going to be mocked DbReader files which implement IDbReader. It will be to simulate the item generation portion so that we have control on what items may come out of the ItemGenerator::GenerateItem call. The additional code will just be helper utilities which will make it easier to setup the tests before running.

Second, I'll go over fixing some of the old tests using the refactored files and the new TestUtilities file.

Finally, I will introduce a new test which goes over two brand-new scenarios related to procedural item generation which makes use of the code that has been written!

Let's get started.

Mocked*DbReader

We have a several IDbReader classes we need to create to serve as mocks.

  • MockedAffixDbReader - Mocks a prefix/suffix "DbReader"
  • MockedNoAffixDbReader - Mocks a "DbReader" that always returns NoPrefix and NoSuffix.
  • MockedItemsDbReader - Mocks a "DbReader" that will always return a Diamond Sword.
MockedAffixDbReader.cs
using System.Collections.Generic;
using InventorySystem;
using InventorySystem.Utils.DbReader;
using InventorySystem.PgItems;

namespace InventorySystemTests.Common
{
    public class MockedAffixDbReader : IDbReader
    {
        public List<Affix> ReadData<Affix>(string filename)
        {
            var affix = new InventorySystem.PgItems.Affix();
            affix.Value = new ValueObject();
            affix.Weight = 15;
            affix.ParentItemClass = new List<string> { "Sword 1" };

            if (filename.Equals("Prefixes"))
            {
                affix.Name = "Prismatic";
                affix.Properties = new List<Property> {
                    new Property() {
                        Name = CharacterStatistics.LABEL_MAG,
                        Value = 67
                    }
                };
            }
            else
            {
                affix.Name = "of the Sun";
                affix.Properties = new List<Property> {
                    new Property() {
                        Name = CharacterStatistics.LABEL_SPR,
                        Value = 33
                    }
                };
            }

            var result = new List<InventorySystem.PgItems.Affix>();
            result.Add(affix);

            return Newtonsoft.Json.JsonConvert.DeserializeObject<List<Affix>>(
                Newtonsoft.Json.JsonConvert.SerializeObject(result)
            );
        }
    }
}

The MockedAffixDbReader depends on the filename parameter to determine whether or not a prefix, or suffix will be returned. Since these affixes share the same schema, we just need to alter a couple of properties. The prefix I have chosen here is Prismatic. Its effect will give the wearer MAG +67. The suffix is of the Sun and I just made it up. To make it interesting, I decided to have the effect of SPR +33.

MockedNoAffixDbReader.cs

using System.Collections.Generic;
using InventorySystem.Utils.DbReader;
using InventorySystem.PgItems;

namespace InventorySystemTests.Common
{
    public class MockedNoAffixDbReader : IDbReader
    {
        public List<Affix> ReadData<Affix>(string filename)
        {
            var affix = new InventorySystem.PgItems.Affix();
            affix.Value = new ValueObject();
            affix.Weight = 15;
            affix.Properties = new List<Property>();
            affix.ParentItemClass = new List<string> { "Sword 1" };

            if(filename.Equals("Prefixes"))
            {
                affix.Name = InventorySystem.PgItems.Affix.AffixLabel.NoPrefix;
            } else
            {
                affix.Name = InventorySystem.PgItems.Affix.AffixLabel.NoSuffix;
            }

            var result = new List<InventorySystem.PgItems.Affix>();
            result.Add(affix);

            return Newtonsoft.Json.JsonConvert.DeserializeObject<List<Affix>>(
                Newtonsoft.Json.JsonConvert.SerializeObject(result)
            );
        }
    }
}

Quite identical to MockedAffixDbReader, MockedNoAffixDbReader will just return NoPrefix, or NoSuffix whenever the filename dictates whether or not the client is to be requesting Prefixes or Suffixes.

MockedItemsDbReader.cs
using System;
using System.Linq;
using System.Collections.Generic;
using InventorySystem.Utils.DbReader;
using InventorySystem.PgItems;

namespace InventorySystemTests.Common
{
    public class MockedItemsDbReader : IDbReader
    {
        public List<Item> ReadData<Item>(string filename)
        {
            var diamondSwordEdge = new ItemClassEdge
            {
                Name = "Diamond Sword",
                Weight = 15
            };

            var sword1 = new InventorySystem.PgItems.Item {
                Name = "Sword 1",
                BodyPart = "Arms",
                ItemClass = new List<ItemClassEdge>() { diamondSwordEdge },
                Properties = new List<Property>(),
                Value = 0
            };

            var diamondSword = new InventorySystem.PgItems.Item
            {
                Name = "Diamond Sword",
                BodyPart = "Arms",
                ItemClass = new List<ItemClassEdge>(),
                Properties = new List<Property> {
                    new Property { Name = "ATK", Value = 100 }
                },
                Value = 5
            };

            var list = new List<InventorySystem.PgItems.Item> {
                sword1,
                diamondSword
            };

            var dbFileContents = Newtonsoft.Json.JsonConvert.SerializeObject(list);

            return Newtonsoft.Json.JsonConvert.DeserializeObject<List<Item>>(dbFileContents);
        }
    }
}

Here we have created a structure to adhere to the Items schema. MockedItemsDbReader will always force the item generator to generate a Diamond Sword. The effect is that this sword will give the wearer ATK +100.

As far as using all these classes go, it is exactly the same way we would use for the regular IDbReader classes. Whenever we need a IDbReader for a test, and we care about controlling what is generated, we can instantiate this sort of implementation to serve as a mock. This reduces test flakiness.

Brotherhood - Yuna

Not a "Diamond Sword", but I personally like the look of Brotherhood from Final Fantasy X, and X-2.

Brotherhood - Tidus

TestUtilities

We previously got away with providing an initial registry for the InventoryManager. We've actually made the inventory manager smarter by now being able to self-register items it does not already contain given a BaseItem. We can leverage this pattern by populating InventoryManager by repeatedly calling Acquire.

SeedInventoryManager will receive an instance of InventoryManager and populate its registry with the same initial contents as before. The content has not change, just the usage.

Our previous tests will now make use of this new TestUtilities.cs file.

TestUtilities.cs
using System.Collections.Generic;
using InventorySystem;
using InventorySystem.Items;
using InventorySystem.PgItems;

namespace InventorySystemTests.Common
{
    public class TestUtilities
    {
        public static ItemName Tonic = new ItemName("Tonic");
        public static ItemName LeatherHelmet = new ItemName("Leather Helmet");
        public static ItemName IronHelmet = new ItemName("Iron Helmet");

        private List<Item> items;

        public TestUtilities(List<Item> items)
        {
            this.items = items;
        }

        public BaseItem TonicItem()
        {
            var tonicItemDef = items.Find(i => i.Name.Equals(Tonic.ToString()));

            return new BaseItem(tonicItemDef);
        }

        public BaseItem LeatherHelmetItem()
        {
            var leatherHelmetItemDef = items.Find(i => i.Name.Equals(LeatherHelmet.ToString()));

            return new BaseItem(leatherHelmetItemDef);
        }

        public BaseItem IronHelmetItem()
        {
            var ironHelmetItemDef = items.Find(i => i.Name.Equals(IronHelmet.ToString()));

            return new BaseItem(ironHelmetItemDef);
        }


        public void SeedInventoryManager(InventoryManager inventoryManager)
        {
            inventoryManager.Acquire(TonicItem());
            inventoryManager.Acquire(TonicItem());
            inventoryManager.Acquire(TonicItem());
            inventoryManager.Acquire(LeatherHelmetItem());
            inventoryManager.Acquire(LeatherHelmetItem());
            inventoryManager.Acquire(IronHelmetItem());
        }
    }
}

TestReceiveAndReleaseItems

It's now time to update this old test suite! Now that we have TestUtilities, let's just update all calls to use the methods that will return the BaseItem types so that they can be passed into the refactored Party::ReceiveItem as it no longer takes in ItemName, but BaseItem!

TestReceiveAndReleaseItems.cs
using Xunit;
using InventorySystemTests.Common;
using InventorySystem.PgItems;
using InventorySystem;
using InventorySystem.Utils.DbReader;

namespace InventorySystemTests
{
    public class TestReceiveAndReleaseItems
    {
        private InventoryManager inventoryManager;
        private Party party;
        private IDbReader itemsDbReader;
        private TestUtilities testUtility;

        public TestReceiveAndReleaseItems()
        {
            itemsDbReader = new ItemsDbReader();
            testUtility = new TestUtilities(itemsDbReader.ReadData<Item>(DbConstants.ItemsDbFile));
        }

        [Fact]
        public void TestSellingAndPurchaseItem()
        {
            inventoryManager = new InventoryManager();

            testUtility.SeedInventoryManager(inventoryManager);

            party = new Party(inventoryManager);

            Character terra = new Character("Terra");
            Character locke = new Character("Locke");

            party.AddMember(terra.Name, terra);
            party.AddMember(locke.Name, locke);

            Assert.Equal(0, party.GetWalletAmount());

            ItemInfo potionInfo;

            party.ReleaseItem(TestUtilities.Tonic, ItemReleaseAction.Sell);

            potionInfo = party.QueryFor(TestUtilities.Tonic);
            Assert.Equal(3, potionInfo.Value);
            Assert.Equal(potionInfo.Value, party.GetWalletAmount());
            Assert.Equal(2, potionInfo.Quantity);

            party.ReceiveItem(testUtility.TonicItem(), ItemReceiveAction.Purchase);

            potionInfo = party.QueryFor(TestUtilities.Tonic);
            Assert.Equal(0, party.GetWalletAmount());
            Assert.Equal(3, potionInfo.Quantity);
        }

        [Fact]
        public void TestAcquireItem()
        {
            inventoryManager = new InventoryManager();
            party = new Party(inventoryManager);

            testUtility.SeedInventoryManager(inventoryManager);

            Character terra = new Character("Terra");
            Character locke = new Character("Locke");

            party.AddMember(terra.Name, terra);
            party.AddMember(locke.Name, locke);

            ItemInfo ironHelmetInfo;

            ironHelmetInfo = party.QueryFor(TestUtilities.IronHelmet);
            Assert.Equal(1, ironHelmetInfo.Quantity);

            party.ReceiveItem(testUtility.IronHelmetItem(), ItemReceiveAction.Loot);

            ironHelmetInfo = party.QueryFor(TestUtilities.IronHelmet);
            Assert.Equal(2, ironHelmetInfo.Quantity);

            party.ReceiveItem(testUtility.IronHelmetItem(), ItemReceiveAction.Treasure);

            ironHelmetInfo = party.QueryFor(TestUtilities.IronHelmet);
            Assert.Equal(3, ironHelmetInfo.Quantity);

            party.ReceiveItem(testUtility.IronHelmetItem(), ItemReceiveAction.Reward);

            ironHelmetInfo = party.QueryFor(TestUtilities.IronHelmet);
            Assert.Equal(4, ironHelmetInfo.Quantity);
        }

        [Fact]
        public void TestDropItem()
        {
            inventoryManager = new InventoryManager();

            testUtility.SeedInventoryManager(inventoryManager);

            party = new Party(inventoryManager);

            Character terra = new Character("Terra");
            Character locke = new Character("Locke");

            party.AddMember(terra.Name, terra);
            party.AddMember(locke.Name, locke);

            ItemInfo ironHelmetInfo;

            ironHelmetInfo = party.QueryFor(TestUtilities.IronHelmet);
            Assert.Equal(1, ironHelmetInfo.Quantity);

            party.ReceiveItem(testUtility.IronHelmetItem(), ItemReceiveAction.Loot);
            party.ReceiveItem(testUtility.IronHelmetItem(), ItemReceiveAction.Loot);
            party.ReceiveItem(testUtility.IronHelmetItem(), ItemReceiveAction.Loot);

            ironHelmetInfo = party.QueryFor(TestUtilities.IronHelmet);
            Assert.Equal(4, ironHelmetInfo.Quantity);

            party.ReleaseItem(TestUtilities.IronHelmet, ItemReleaseAction.Drop);
            party.ReleaseItem(TestUtilities.IronHelmet, ItemReleaseAction.Drop);

            ironHelmetInfo = party.QueryFor(TestUtilities.IronHelmet);
            Assert.Equal(2, ironHelmetInfo.Quantity);
        }
    }
}

TestUseEquipAndUnequipItems

Same as TestReceiveAndReleaseItems, we have made substantial updates to the usage in how the party will receive items. Instead of using ItemName, we now pass in the BaseItem acquired from TestUtilities. I will skip showing the code here as the changes are similar to the previous file.

Generating Items: Tests

I've left testing the new item generation code we've written to its own section. πŸ˜„ It's one that I'm pretty proud of, and I thought I'd make it stand out a little...

There are two key scenarios which I find useful to test:

  1. Generating a basic item procedurally given a data set. This we want to test if a drop from a monster will give us a usable item. Equpping this item to a party member should augment their battle stats.
  2. Generating the same basic item as (1), but now with affixes applied. We want to see if equipping this item will boost the other stats of the character.

For Scenario 1, the TestGenerateBaseItem will generate a one-handed Diamond Sword which when equipped, will give the party member a boost in ATK by 100.

We first instantiate our IDbReader classes with the mocks we had created earlier. Then we modify the monster drops to force the item generation algorithm to always pick a one-handed sword.

The algorithm will then look into the data set in which the MockedItemsDbReader returned. In this case, only a Diamond Sword is returned.

We use that result and equip it on the active party member -- which is Terra. From there, we check to see if Terra's ATK stat has increased to 100.

In Scenario 2, TestGenerateComplexItem will do most of the same as Scenario 1 except that it makes the item more interesting by affixing the Diamond Sword with a prefix and suffix. After affixing the item, the Diamond Sword turns into Prismastic Diamond Sword of the Sun. Don't laugh. It was the best I could do at the time, and it doesn't sound that bad. πŸ˜†

Again, we use that result and equip it on the active party member, and check to see if the ATK, MAG, and SPR stats have increased.

TestGeneratingItems.cs

using System.Collections.Generic;
using Xunit;
using InventorySystemTests.Common;
using InventorySystem.Utils.DbReader;
using InventorySystem.PgItems;
using InventorySystem;
using InventorySystem.Items;

namespace InventorySystemTests
{
    public class TestGeneratingItems
    {
        private InventoryManager inventoryManager;
        private Party party;

        private List<Monster> monsters;
        private ItemGenerator generator;

        private IDbReader monstersDbReader;
        private IDbReader itemsDbReader;
        private IDbReader prefixesDbReader;
        private IDbReader suffixesDbReader;

        [Fact]
        public void TestGenerateBaseItem()
        {
            monstersDbReader = new MonstersDbReader();
            monsters = monstersDbReader.ReadData<Monster>(DbConstants.MonstersDbFile);

            itemsDbReader = new MockedItemsDbReader();
            prefixesDbReader = new MockedNoAffixDbReader();
            suffixesDbReader = new MockedNoAffixDbReader();

            generator = new ItemGenerator(
                itemsDbReader.ReadData<Item>(""),
                prefixesDbReader.ReadData<Affix>("Prefixes"),
                suffixesDbReader.ReadData<Affix>("Suffixes")
            );

            var originalMonstersItemClass = new List<ItemClassEdge>(monsters[0].ItemClass);
            monsters[0].ItemClass.RemoveAll(i => !i.Name.Equals("Sword 1"));

            var item = generator.GenerateItem(monsters[0]);

            var diamondSword = new Item
            {
                Name = "Diamond Sword",
                BodyPart = "Arms",
                ItemClass = new List<ItemClassEdge>(),
                Properties = new List<Property> { new Property { Name = "ATK", Value = 100 } },
                Value = 5
            };
            Assert.NotNull(item);
            Assert.Equal(diamondSword, item);

            monsters[0].ItemClass = originalMonstersItemClass;

            inventoryManager = new InventoryManager();
            party = new Party(inventoryManager);

            Character terra = new Character("Terra");
            party.AddMember(terra.Name, terra);

            var DiamondSwordName = new ItemName(diamondSword.Name);
            var diamondSwordItem = new BaseItem(diamondSword);
            party.ReceiveItem(diamondSwordItem, ItemReceiveAction.Treasure);

            party.SetActiveCharacter("Terra");
            party.EquipItem(DiamondSwordName, BodyPart.Arms);

            Assert.Equal(100, terra.Statistics.ATK);
        }

        [Fact]
        public void TestGenerateComplexItem()
        {
            monstersDbReader = new MonstersDbReader();
            monsters = monstersDbReader.ReadData<Monster>(DbConstants.MonstersDbFile);

            itemsDbReader = new MockedItemsDbReader();
            prefixesDbReader = new MockedAffixDbReader();
            suffixesDbReader = new MockedAffixDbReader();

            generator = new ItemGenerator(
                itemsDbReader.ReadData<Item>(""),
                prefixesDbReader.ReadData<Affix>("Prefixes"),
                suffixesDbReader.ReadData<Affix>("Suffixes")
            );

            var originalMonstersItemClass = new List<ItemClassEdge>(monsters[0].ItemClass);
            monsters[0].ItemClass.RemoveAll(i => !i.Name.Equals("Sword 1"));

            var item = generator.GenerateItem(monsters[0]);

            var PrismaticDiamondSwordOfTheSunItem = new Item
            {
                Name = "Prismatic Diamond Sword of the Sun",
                BodyPart = "Arms",
                ItemClass = new List<ItemClassEdge>(),
                Properties = new List<Property> {
                    new Property { Name = CharacterStatistics.LABEL_ATK, Value = 100 },
                    new Property { Name = CharacterStatistics.LABEL_MAG, Value = 67 },
                    new Property { Name = CharacterStatistics.LABEL_SPR, Value = 33 }
                },
                Value = 5
            };

            Assert.Equal(PrismaticDiamondSwordOfTheSunItem, item);

            inventoryManager = new InventoryManager();
            party = new Party(inventoryManager);

            Character terra = new Character("Terra");
            party.AddMember(terra.Name, terra);

            party.ReceiveItem(new BaseItem(item), ItemReceiveAction.Loot);

            party.SetActiveCharacter("Terra");
            party.EquipItem(new ItemName("Prismatic Diamond Sword of the Sun"), BodyPart.Arms);

            Assert.Equal(100, terra.Statistics.ATK);
            Assert.Equal(67, terra.Statistics.MAG);
            Assert.Equal(33, terra.Statistics.SPR);

            party.UnequipItem(BodyPart.Arms);
            Assert.Equal(0, terra.Statistics.ATK);
            Assert.Equal(0, terra.Statistics.MAG);
            Assert.Equal(0, terra.Statistics.SPR);
        }
    }
}

 

The Story So Far (Again)

Whew, that was a lot of work! Hope you enjoyed this one... In the next chapter of our story, we're going to dive into some Unity stuff and see what we can do to get this hooked up into a real game engine. Stay tuned!

UML Inventory System

Full Diagram View