Le Cube ◇ Mass Psychosis

The Blog

ReaScripts for Cockos REAPER
FMOD
Test-Focused Development
TDD-ing Avatar Health in C# and C++
Part 1&2 - The Avatar Health assignment
Part 3 - Implementation begins - C#
Part 3 - Implementation begins - C++
Part 4 - Taking Damage
Part 5 - The Dying part
Part 6 - The Replenishing part
Part 7 - The Increasing and Max part
Part 8 - Adding the Config
TDD-ing Chess in C#
Lifting

TDD-ing Avatar Health in C# and C++

Part 8 - Adding the Config

by Egill Antonsson
published on 2022 Aug 16

The game balancing config

The thought came early (in part 3) that a game balance config for values like StartingUnits,
would be valuable to have for balancing out during the development period.
The config should be accessible and validate the values (ideally immediately when changed),
and then later at runtime be injected to the Health instance.
There are other values in Health that are suitable, thus the config will include:

  • StartingUnits
  • PointsPerUnit
  • MaxUnits
  • MaxNegativeUnitsForInstantKillProtection

Using ScriptableObject

Assuming that the game designer can do basic work in Unity and the version control system,
we can keep the config solution completely within Unity.
I'll thus base it on Unity's ScriptableObject,
a data container that can be used to save large amounts of data.
I create folder ScriptableObjects/ at Assets/Scripts/Runtime/ and create script GameConfig within it.

// GameConfig.cs
using UnityEngine;

[CreateAssetMenu(fileName = "GameConfigInstance",
menuName = "Avatar Health/Create GameConfig Instance",
order = 1)]
public class GameConfig : ScriptableObject {
	public int StartingUnits = 3;
	public int PointsPerUnit = 4;
	public int MaxUnits = 30;
	public int MaxNegativeUnitsForInstantKillProtection = -5
}

I create another folder ScriptableObjectsInstances/ at Assets/
and because of the code above I can now right click on the folder / click at Assets at top menu,
and select menu item Create > Avatar Health > Create GameConfig Instance.
As the name applies this creates a scriptable object instance based on the GameConfig class 'template'.
I use the given default filename GameConfigInstance (file ends with .asset).

Create Game Config Instance via menu item
Create GameConfig Instance via menu item

I can now view and edit the Game Config Instance via the Inspector window.

Game Config Instance via the Inspector window
Game Config Instance via the Inspector window

I can change the values as I see fit and the change will be saved on the instance.
But there is no domain validation for the values, so I TDD cycle it.

TDD-ing the validation

Unit testing logic in scriptable objects can quickly become complicated.
For this reason, and also for Reusability and Separation of Concern,
I will implement the generic validation logic in a utility class which will be easy to TDD.
Then I'll make the config process the validation using the utility logic.

RED

I create the folder Utils in the test folder
(Assets/Scripts/Tests/EditMode/) and create script ValidationTest within it.
I take into consideration the requirements for both the config
and the other validations that Health does, and then write the tests.

// ValidationTest.cs
using NUnit.Framework;

[TestFixture]
public class ValidationTest
{
	public class Validate
	{
		[TestCase(12, 1)]
		[TestCase(1, 1)]
		[TestCase(4, 2)]
		[TestCase(2, 2)]
		[TestCase(-20, int.MinValue, -1)]
		[TestCase(-1, int.MinValue, -1)]
		public void Passes(int v, int lowestValidV, int highestValidV = int.MaxValue)
		{
			(bool, int, string) ret = Validation.Validate(v, lowestValidV, highestValidV);
			Assert.That(ret.Item1, Is.True);
			Assert.That(ret.Item2, Is.EqualTo(v));
			Assert.That(ret.Item3, Is.EqualTo(""));
		}

		[TestCase(0, 1)]
		[TestCase(-1, 0)]
		public void Fails_WhenValueLower(int v, int lowestValidV)
		{
			(bool, int, string) ret = Validation.Validate(v, lowestValidV, int.MaxValue);
			Assert.That(ret.Item1, Is.False);
			Assert.That(ret.Item2, Is.EqualTo(lowestValidV));
			Assert.That(ret.Item3, Does.Match("invalid").IgnoreCase);
		}

		[TestCase(0, int.MinValue, -1)]
		[TestCase(1, int.MinValue, 0)]
		public void Fails_WhenValueHigher(int v, int lowestValidV, int highestValidV)
		{
			(bool, int, string) ret = Validation.Validate(v, lowestValidV, highestValidV);
			Assert.That(ret.Item1, Is.False);
			Assert.That(ret.Item2, Is.EqualTo(highestValidV));
			Assert.That(ret.Item3, Does.Match("invalid").IgnoreCase);
		}
	}
}

I wrote tests for all meaningful cases based on the requirements in one step.
The validation logic returns a tuple:

/*success*/(bool IsValid = true, int Value, string failMessage = "")
/*fail*/(bool IsValid = false, int Value /*corrected to valid edge*/, string failMessage)

The using code should then decide what to do.

GREEN

I mirror the location of the test, thus create the folder Utils in the production code folder (Assets/Scripts/Runtime/) and create script Validation within it.
I implement it gradually until I get all the tests passing.

// Validation.cs
public static class Validation
{
	public static (bool, int, string) Validate(int v, int lowestValidV, int highestValidV = int.MaxValue)
	{
		var message = "";
		if (v >= lowestValidV && v <= highestValidV)
		{
			return (true, v, message);
		}

		message = $"Value {v} is invalid, it should be within the range of {lowestValidV} and {highestValidV}";
		int retV = highestValidV == int.MaxValue ? lowestValidV : highestValidV;

		return (false, retV, message);
	}
}

Processing the validation in the config

I want to make sure the validation can't be bypassed
thus I'll prevent the game designer from saving invalid values to the config,
and provide some message in the Unity editor when an invalid value is corrected.

// GameConfig.cs
private void OnValidate()
{
	var v = Validation.Validate(StartingUnits, 1);
	StartingUnits = ProcessValidation(v, nameof(StartingUnits));

	v = Validation.Validate(PointsPerUnit, 2);
	PointsPerUnit = ProcessValidation(v, nameof(PointsPerUnit));

	v = Validation.Validate(MaxNegativeUnitsForInstantKillProtection, int.MinValue, -1);
	MaxNegativeUnitsForInstantKillProtection = ProcessValidation(v, nameof(MaxNegativeUnitsForInstantKillProtection));

	v = Validation.Validate(MaxUnits, StartingUnits);
	MaxUnits = ProcessValidation(v, nameof(MaxUnits));
}

private int ProcessValidation((bool IsValid, int Value, string FailMessage) validation, string fieldName)
{
	if (!validation.IsValid)
	{
		Debug.LogWarning(validation.FailMessage + $", for '{fieldName}'. Will set value to {validation.Value}.");
	}
	return validation.Value;
}

I use OnValidate, a editor-only function that Unity calls when the script is loaded or a value changes in the Inspector
(part of the ScriptableObject).

The validation at work

Unity: Config Validation in the Inspector and Console
Unity: Config Validation in the Inspector and Console

As the gif shows above, the logic accepts valid values (the first one)
but prevents invalid values being set by reverting to the valid 'edge' value, and logging a message to the Console.

  • reverts to 1 for StartingUnits
  • reverts to 2 for PointsPerUnit
  • reverts to -1 for MaxNegativePointsForInstantKillProtection

Below is the gif for the MaxUnits validation.

Unity: MaxUnits Validation in the Inspector and Console
Unity: MaxUnits Validation in the Inspector and Console

MaxUnits validation is based on the values of StartingUnits
thus MaxUnits can become invalid when StartingUnits is set to a valid value,
(as the latter half of the gif conveys).
The logged message in the Console clarifies this occurrence.

Make Health use the config

I want Health to use the config
and not provide another way to inject those values,
thus I don't need to validate those values again in Health.
(I have ownership of the config code and
I am confident that its validation will always work as expected).

I will refactor the health code in the steps presented below.
This is about a design change in the production code (wholistic refactoring),
and it this case it makes more sense to start with the production code.

Refactor Health

// Health.cs
private readonly GameConfig config;

public Health(GameConfig gameConfig)
{
	config = gameConfig;
	FullPoints = CurrentPoints = config.StartingUnits * config.PointsPerUnit;
}

I inject the GameConfig instance into the constructor,
and store it in a member and then access the values from the member.
The member is private readonly and thus encapsulated and immutable within the class.
I remove the old startingUnits validation (as the config is already validated).

// Health.cs
public int PointsPerUnit => config.PointsPerUnit;
public int MaxUnits => config.MaxUnits;
public int MaxNegativeUnitsForInstantKillProtection => config.MaxNegativeUnitsForInstantKillProtection;

I refactor these constants into read-only properties,
getting the value from the config.
This way the config values can't be set via the Health instance (encapsulation).

Refactor HealthTest

Now many tests will fail,
mainly because they are not injecting the config into the constructor.
Let's refactor and fix them.

// HealthTest.cs
public class HealthTest
{
	public static Health MakeHealth(int startingUnits)
	{
			var config = MakeConfig(startingUnits);
			var health = new Health(config);
			return health;
	}

	public static GameConfig MakeConfig(int startingUnits = 3)
	{
			var config = ScriptableObject.CreateInstance<GameConfig>();
			config.StartingUnits = startingUnits;
			config.PointsPerUnit = 4;
			config.MaxUnits = 30;
			return config;
	}

I create these helper methods above.

MakeConfig creates the config
and sets the startingUnits via parameter.
It also sets the other values for better Resistance to refactoring
(the tests will still work if the default values are changed in the scriptable object).

MakeHealth creates and return a Health instance (using MakeConfig).

For most tests it is sufficient to swap the MakeHeath
for the new Health construction, as shown below
(one command execution with the IDE).

 HealthTest.cs
[Test]
public void IsDead_IsFalse()
{
	var health = MakeHealth(3);
	Assert.That(health.IsDead, Is.False);
}

There are 3 tests that refer to maxUnits to set up StartingUnits the the test.
These tests use MakeConfig directly and then create the Health instance.

// HealthTest.cs
[Test]
public void ReturnsTrue_WhenStartingUnitsAtMax()
{
	var config = MakeConfig();
	config.StartingUnits = config.MaxUnits;
	var health = new Health(config);
	Assert.That(health.IsMaxUnitsReached, Is.True);
}

Now only test ThrowsError_WhenStartingPointsIsInvalid fails.
I remove it as I'm no longer validating StartingUnits in Health.
Now all the tests pass again.

Reuse the utility Validation

Since now I have a utility Validation class
I want to reuse it in the Health methods,
thus I refactor the ValidatePoints method:

// Health.cs
private static void ValidatePoints(int points, int lowestValidValue)
{
	(bool IsValid, int Value, string FailMessage) v = Validation.Validate(points, lowestValidValue);
	if (!v.IsValid)
	{
		throw new ArgumentOutOfRangeException(nameof(points), v.FailMessage);
	}
}

Now the config has been added and used, the Unity Test Runner looks like this:

Test Runner after config added and used
Test Runner after config added and used