“We have so much time and so little to do. Strike that, reverse it.” ― Roald Dahl
Overview
If you aren’t familiar with weapons in Pathfinder, you can see a table of weapons and information about them such as how they apply to attack and damage rolls here.
I’ve been chipping away at features related to the hero “Class”, and this lesson will continue in that effort. Each class specifies various kinds of training across various categories like skills, attacks and defenses. Within the “Attacks” category, you might see something like “Trained in simple weapons”. Then depending on the weapon used in an attack, you can add a proficiency bonus to your attacks based on the relevant training.
Using the above example, let’s suppose our character is trained in simple weapons, and attacks with a “Dagger” which is categorized as “simple”. In this case, we can add a bonus to attack rolls based on the training (equal to 2 plus the hero’s level). However, if we choose to equip a “Longsword” which is categorized as “martial”, then we would not get that bonus, unless we also have training with martial weapons.
The goal of this lesson is to represent attack training. Of course it wouldn’t mean much without having a concept of weapons, so we will need to add at least some minimum levels of support for them as well. Since I haven’t yet implemented a concept of equipment, we will just use some simple unit tests to demonstrate that we can determine the proficiency of a hero for a given weapon based on relevant training.
Getting Started
Feel free to continue from where we left off, or download and use this project here. It has everything we did in the previous lesson ready to go.
Weapon Category System
Weapons have a category such as Simple, Martial, and Advanced. A lot of the training provided by a class is aimed at these categories. For example, many classes have attack training that says: “Trained in simple weapons”. We will create an enum to represent the possible weapon categories, then add an Entity Table System so that we can associate it with entities.
Create a new folder at Assets -> Scripts -> Component named Equipment. Then create a subfolder inside that named Weapon. Inside the weapon folder, create a script named WeaponCategorySystem and add the following:
[System.Flags] public enum WeaponCategory { None = 0, Advanced = 1 << 0, Ammunition = 1 << 1, Martial = 1 << 2, Simple = 1 << 3, Unarmed = 1 << 4 } public partial class Data { public CoreDictionary<Entity, WeaponCategory> weaponCategory = new CoreDictionary<Entity, WeaponCategory>(); } public interface IWeaponCategorySystem : IDependency<IWeaponCategorySystem>, IEntityTableSystem<WeaponCategory> { } public class WeaponCategorySystem : EntityTableSystem<WeaponCategory>, IWeaponCategorySystem { public override CoreDictionary<Entity, WeaponCategory> Table => IDataSystem.Resolve().Data.weaponCategory; } public partial struct Entity { public WeaponCategory WeaponCategory { get { return IWeaponCategorySystem.Resolve().Get(this); } set { IWeaponCategorySystem.Resolve().Set(this, value); } } }
The WeaponCategory was implemented as Flags which is an easy way to represent 0, 1, or even multiple categories with a single variable. In practice I may still choose to track training for each category separately. I haven’t decided yet. The rest of the code snippet shows a familiar pattern shown in most of our Entity Table Systems – a partial “Data” showing a new table to work with, as well as an interface and concrete system for managing that new table. Finally we have a partial “Entity” definition to make a convenient property to grab the WeaponCategory from a given Entity instance.
Weapon Group System
Pathfinder has a second way to group weapons. Consider the Gunslinger class which has the following attack training: “Expert in simple firearms and crossbows”. This is in addition to the “Trained in simple weapons” but is greater in specificity. So a gunslinger could wield a dagger well enough, but shines when equipped with a firearm.
Just like we did with the weapon category, we will be creating an enum to represent the various groups of weapons, as well as a new Entity Table System to associate the data with Entities. Create a new script in the same folder named WeaponGroupSystem
[System.Flags] public enum WeaponGroup { None = 0, Axe = 1 << 0, Bomb = 1 << 1, Bow = 1 << 2, Brawling = 1 << 3, Club = 1 << 4, Crossbow = 1 << 5, Dart = 1 << 6, Firearm = 1 << 7, Flail = 1 << 8, Hammer = 1 << 9, Knife = 1 << 10, Pick = 1 << 11, Polearm = 1 << 12, Shield = 1 << 13, Sling = 1 << 14, Spear = 1 << 15, Sword = 1 << 16 } public partial class Data { public CoreDictionary<Entity, WeaponGroup> weaponGroup = new CoreDictionary<Entity, WeaponGroup>(); } public interface IWeaponGroupSystem : IDependency<IWeaponGroupSystem>, IEntityTableSystem<WeaponGroup> { } public class WeaponGroupSystem : EntityTableSystem<WeaponGroup>, IWeaponGroupSystem { public override CoreDictionary<Entity, WeaponGroup> Table => IDataSystem.Resolve().Data.weaponGroup; } public partial struct Entity { public WeaponGroup WeaponGroup { get { return IWeaponGroupSystem.Resolve().Get(this); } set { IWeaponGroupSystem.Resolve().Set(this, value); } } }
Once again I implemented the enum as Flags so that I can easily represent more than one group with a single value (like the Gunslinger’s firearms AND crossbows). We also added the Data, Interface, Entity Table System and convenient Entity property that are common practice for our patterns.
Weapon Filter System
Next I will create a data structure and system to help “filter” matches of an Entity with a certain weapon pattern that would relate to attack training. Things I might care about are the name of the weapon, the weapon category, or the weapon group. The “name” might come into play for a hero with “Trained in the favored weapon of your deity…”. The category and group are related to the systems we just implemented.
Create a new script in the same folder named WeaponFilterSystem and add the following:
[System.Serializable] public class WeaponFilter { public string name; public WeaponCategory category; public WeaponGroup group; } public interface IWeaponFilterSystem : IDependency<IWeaponFilterSystem> { public bool Matches(WeaponFilter filter, Entity target); } public class WeaponFilterSystem : IWeaponFilterSystem { public bool Matches(WeaponFilter filter, Entity target) { if (!string.IsNullOrEmpty(filter.name) && !string.Equals(filter.name, target.Name)) return false; if (filter.category != WeaponCategory.None) { var check = filter.category & target.WeaponCategory; if (check == WeaponCategory.None) return false; } if (filter.group != WeaponGroup.None) { var check = filter.group & target.WeaponGroup; if (check == WeaponGroup.None) return false; } return true; } }
First we added the WeaponFilter model. It can hold the various means by which we might want to determine whether or not a weapon matches a given requirement. Often enough we would care only for one of those three, but occasionally we would need a combination such as for the Expert training in “Simple Firearms”. The System has a method called “Matches” which can determine whether a given entity (weapon) matches a given filter.
If the filter’s “name” is empty, then we ignore it. Otherwise, it would have to match.
If the filter’s “category” is any value other than “None”, then the weapon needs to have a category that is included in the flags.
Similarly if the filter’s “group” is any value other than “None”, then the weapon needs to have a group that is included in the flags.
If all the required checks pass, then the target weapon matches the filter and the method would return a `true`. If any check failed, then the target weapon does not match, and the method would return a `false`.
Weapon Proficiency System
Attack Training can be very specific, involving combinations of categories and groups or even specifically named weapons. Therefore, it doesn’t make sense to have a system per variation – there would just be too many, and it wouldn’t scale well. Instead, I will create a single system that can hold the idea of being trained based on a “filter”. Any given hero can have any number of weapon training entries, each specifying their own filter and associated amount of proficiency.
Create a new script in the same folder named WeaponProficiencySystem and add the following:
using System; using System.Collections.Generic; [Serializable] public class WeaponTraining { public WeaponFilter filter = new WeaponFilter(); public Proficiency proficiency; } [Serializable] public class WeaponProficiency { public List<WeaponTraining> training = new List<WeaponTraining>(); } public partial class Data { public CoreDictionary<Entity, WeaponProficiency> weaponProficiency = new CoreDictionary<Entity, WeaponProficiency>(); } public interface IWeaponProficiencySystem : IDependency<IWeaponProficiencySystem>, IEntityTableSystem<WeaponProficiency> { void AddWeaponTraining(WeaponTraining training, Entity entity); Proficiency GetProficiency(Entity entity, Entity weapon); } public class WeaponProficiencySystem : EntityTableSystem<WeaponProficiency>, IWeaponProficiencySystem { public override CoreDictionary<Entity, WeaponProficiency> Table => IDataSystem.Resolve().Data.weaponProficiency; public void AddWeaponTraining(WeaponTraining training, Entity entity) { if (!Has(entity)) Set(entity, new WeaponProficiency()); var value = Get(entity); value.training.Add(training); } public Proficiency GetProficiency(Entity entity, Entity weapon) { var result = Proficiency.Untrained; if (!Has(entity)) return result; var allTraining = Get(entity).training; foreach (var training in Get(entity).training) { var prof = GetProficiency(weapon, training); if (prof > result) result = prof; } return result; } Proficiency GetProficiency(Entity weapon, WeaponTraining training) { if (IWeaponFilterSystem.Resolve().Matches(training.filter, weapon)) return training.proficiency; return Proficiency.Untrained; } }
The WeaponTraining represents training to a particular level of proficiency for any match of the filter – you can be as specific or as generic as you wish. The WeaponProficiency is the collection of all attack trainings that an associated hero will have learned. That association is based on the Data class “weaponProficiency” table.
The interface, IWeaponProficiencySystem exposes two methods, both of which are implemented in the class, WeaponProficiencySystem.
The first method, AddWeaponTraining is designed to associate a WeaponTraining with an Entity (probably a hero). It first checks to make sure that the “entity” already has an associated WeaponProficiency object, and if not, will create and assign one. Then it will “Get” the associated object, since at this point we know for sure there will be one. Finally, we “Add” the training to the collection of attack trainings.
The second method, GetProficiency should look through all of an Entity’s trainings and return the highest level of proficiency that matches the specified weapon Entity. If there are no trainings that match, the method will just return an “Untrained” value – in other words there will not be a bonus based on training.
Weapon Injector
We have created multiple systems that need to be injected, so we should go ahead and create a new Injector. Create a new script in the same folder named WeaponInjector and add the following:
public static class WeaponInjector { public static void Inject() { IWeaponCategorySystem.Register(new WeaponCategorySystem()); IWeaponFilterSystem.Register(new WeaponFilterSystem()); IWeaponGroupSystem.Register(new WeaponGroupSystem()); IWeaponProficiencySystem.Register(new WeaponProficiencySystem()); } }
Open the ComponentInjector and add the following to its Inject method:
WeaponInjector.Inject();
Unit Tests
At this point we can successfully represent Attack Training for a hero for a variety of weapons. Of course we have not actually created any Entity Recipes for actual weapons, nor have we created a system that can handle equipping the weapons, or making use of the weapons during combat. Lots to do. For the sake of demonstration, and because it never hurts to test your code, let’s add some unit tests to make sure everything is working.
Weapon Filter System Tests
First, let’s test to make sure that we are able to successfully match a configured weapon entity with a WeaponFilter. I would like to create the test in a hierarchy that mirrors the Scripts folder structure, but inside the Tests directory. Therefore, create an “Equipment/Weapon” path there as well. Then inside the Weapon folder, create the new script WeaponFilterSystemTests and add the following:
using NUnit.Framework; public class WeaponFilterSystemTests { const string weaponName = "Sword"; WeaponFilterSystem sut; Entity weapon; }
So far, we have imported the testing framework, and have created some fields for the class that will be reused among our tests. The “weaponName” is needed to check filtering based on the name of a weapon, and uses a const so that we can compare names without worrying about tests failing from a typo.
The “sut” is our “subject under test” – and will be the system for which we want to test the logic we have implemented.
The “weapon” will be an Entity that is preconfigured with a name, category and group, so we can test both positive and negative cases of matching.
Add the following:
[SetUp] public void SetUp() { INameSystem.Register(new NameSystem()); IWeaponCategorySystem.Register(new WeaponCategorySystem()); IWeaponGroupSystem.Register(new WeaponGroupSystem()); sut = new WeaponFilterSystem(); IDataSystem.Register(new MockDataSystem()); IDataSystem.Resolve().Create(); weapon = CreateSimpleSword(); } [TearDown] public void TearDown() { INameSystem.Reset(); IWeaponCategorySystem.Reset(); IWeaponGroupSystem.Reset(); IDataSystem.Reset(); }
We will create a “weapon” based on Entity composition. Our sample weapon will have a name, weapon category and weapon group, so we use the “Setup” method to “Register” those systems, and the “TearDown” method to “Reset” them. Of course to associate entities with data we will also need to create a Data System, but for the sake of the test, it can just be a “Mock” Data System which merely holds the data in memory.
Add the following:
Entity CreateSimpleSword() { var weapon = new Entity(123); weapon.Name = weaponName; weapon.WeaponCategory = WeaponCategory.Simple; weapon.WeaponGroup = WeaponGroup.Sword; return weapon; }
The CreateSimpleSword method creates an Entity configured with a name, category and group and returns the reference to the created result. We create a weapon that is named “Sword” which is coincidentally a “Simple” (Category) “Sword” (Group).
Add the following:
[Test] public void TestMatchingName() { // Arrange var filter = new WeaponFilter(); filter.name = weaponName; // Act var result = sut.Matches(filter, weapon); // Assert Assert.IsTrue(result); } [Test] public void TestNonMatchingName() { // Arrange var filter = new WeaponFilter(); filter.name = "Foo"; // Act var result = sut.Matches(filter, weapon); // Assert Assert.IsFalse(result); }
Here I have added both a positive and negative test case for matching against a weapon’s name. The “success” test creates a filter that is configured using the same “const” name that the weapon was created with, so the strings should definitely match. The “failure” test creates a filter with a random name, “Foo” which does NOT match the const weapon name.
Add the following:
[Test] public void TestMatchingCategory() { // Arrange var filter = new WeaponFilter(); filter.category = WeaponCategory.Simple; // Act var result = sut.Matches(filter, weapon); // Assert Assert.IsTrue(result); } [Test] public void TestNonMatchingCategory() { // Arrange var filter = new WeaponFilter(); filter.category = WeaponCategory.Advanced; // Act var result = sut.Matches(filter, weapon); // Assert Assert.IsFalse(result); }
Here I have added both a positive and negative test case for matching against a weapon’s category. The first test configures a filter with the same “Simple” category as the sword, while the second test uses a different “Advanced” category.
Add the following:
[Test] public void TestMatchingGroup() { // Arrange var filter = new WeaponFilter(); filter.group = WeaponGroup.Sword; // Act var result = sut.Matches(filter, weapon); // Assert Assert.IsTrue(result); } [Test] public void TestNonMatchingGroup() { // Arrange var filter = new WeaponFilter(); filter.group = WeaponGroup.Axe; // Act var result = sut.Matches(filter, weapon); // Assert Assert.IsFalse(result); }
Once again I add both positive and negative tests, this time for matching against a weapon’s group. The “success” test creates a filter with the same “Sword” group, and the “failure” test creates a filter with an “Axe” group.
Add the following:
[Test] public void TestMatchingFromMultipleOptions() { // Arrange var filter = new WeaponFilter(); filter.category = WeaponCategory.Advanced | WeaponCategory.Simple; filter.group = WeaponGroup.Axe | WeaponGroup.Bow | WeaponGroup.Sword; // Act var result = sut.Matches(filter, weapon); // Assert Assert.IsTrue(result); } [Test] public void TestNonMatchingFromMultipleOptions() { // Arrange var filter = new WeaponFilter(); filter.category = WeaponCategory.Advanced | WeaponCategory.Martial; filter.group = WeaponGroup.Axe | WeaponGroup.Bow | WeaponGroup.Club; // Act var result = sut.Matches(filter, weapon); // Assert Assert.IsFalse(result); }
Our final set of tests revolves around having more than one option set on the filter. The success test must match a category of either “Advanced” or “Simple” (it matches “Simple”). It must also match a group of “Axe”, “Bow”, or “Sword” (it matches “Sword”).
The failure test looked for a category match of “Advanced” or “Martial”, which our sample weapon does not match. It also looks for a group match of “Axe”, “Bow” or “Club” – none of which match our sword.
Weapon Proficiency System Tests
Create another test file in the same folder named WeaponProficiencySystemTests and add the following
using NUnit.Framework; public class WeaponProficiencySystemTests { WeaponProficiencySystem sut; Entity hero; Entity weapon; }
We start out by importing the testing framework. We also add a few fields that will be used for our various tests. The “subject under test” (sut) is the system that has logic to test. The “hero” is an Entity that will have associated attack training, and the “weapon” is an Entity that will have associated name, weapon category, and group which we can use against a filter.
Add the following:
[SetUp] public void SetUp() { IWeaponCategorySystem.Register(new WeaponCategorySystem()); IWeaponFilterSystem.Register(new WeaponFilterSystem()); IWeaponGroupSystem.Register(new WeaponGroupSystem()); sut = new WeaponProficiencySystem(); IDataSystem.Register(new MockDataSystem()); IDataSystem.Resolve().Create(); hero = new Entity(123); weapon = new Entity(456); weapon.WeaponCategory = WeaponCategory.Simple; weapon.WeaponGroup = WeaponGroup.Sword; } [TearDown] public void TearDown() { IWeaponCategorySystem.Reset(); IWeaponFilterSystem.Reset(); IWeaponGroupSystem.Reset(); IDataSystem.Reset(); }
For the weapon, we need systems to associate Weapon Category and Weapon Group. We also need the System that understands whether or not a weapon matches (the weapon Filter). We need a Proficiency System to associate attack training with our hero. Finally we need the Mock Data System to hang on to everything. We use the “Setup” method to create all of the needed systems and Register them. We also create and configure the “hero” and “weapon” Entities. We then use the “TearDown” method to Reset the systems when the tests end.
Add the following:
WeaponTraining CreateTraining(WeaponCategory category, WeaponGroup group, Proficiency proficiency) { var result = new WeaponTraining(); result.filter.category = category; result.filter.group = group; result.proficiency = proficiency; return result; }
This is just a convenient method to create a “WeaponTraining” based on a category, group and proficiency – which is all I need for the following tests.
Add the following:
[Test] public void TestDefaultProficiency() { Assert.AreEqual(sut.GetProficiency(hero, weapon), Proficiency.Untrained); }
The first test is to determine the “default” value returned by our system when there are no matching attack trainings. I know there are no matches, because in this test I haven’t even given the hero any attack training. The result is that it should return an “Untrained” proficiency for the given hero and weapon.
Add the following:
[Test] public void TestAddWeaponTraining() { // Arrange var training = CreateTraining(WeaponCategory.Simple, WeaponGroup.None, Proficiency.Trained); // Act sut.AddWeaponTraining(training, hero); // Assert Assert.IsTrue(sut.Has(hero)); Assert.AreEqual(sut.GetProficiency(hero, weapon), Proficiency.Trained); }
This test makes two assertions. First, we create a “WeaponTraining” object, then assign it to the hero using the system. At that point the system “Has” a value for our hero, which is our first assertion to test. The second assertion is that we expect a non-default result for the proficiency since the training we created is designed to match the sample weapon.
Add the following:
[Test] public void TestGetHighestTrainingProficiency() { // Arrange var simpleTraining = CreateTraining(WeaponCategory.Simple, WeaponGroup.None, Proficiency.Trained); var expertTraining = CreateTraining(WeaponCategory.None, WeaponGroup.Sword, Proficiency.Expert); // Act sut.AddWeaponTraining(simpleTraining, hero); sut.AddWeaponTraining(expertTraining, hero); // Assert Assert.AreEqual(sut.GetProficiency(hero, weapon), Proficiency.Expert); }
Our final test makes sure that we always return the “highest” matching proficiency for a given weapon. It does this by creating multiple matching trainings, but making the last have a higher proficiency.
Demo
The demo for this lesson is to return to Unity and use the Test Runner to verify that our new tests pass. Assuming you have no mistakes in copying my example code then all of the tests should pass. Great job!
Summary
In this lesson, we created a proficiency system that is a little more complex than our other skill systems. Whereas before we created a unique system per skill, this time training can be on generic or specific terms or a mix of both. Therefore we created a reusable system that could contain any such variant.
If you got stuck along the way, feel free to download the finished project for this lesson here.
If you find value in my blog, you can support its continued development by becoming my patron. Visit my Patreon page here. Thanks!