As the game gets more features, it becomes more difficult to manually test. So, we will add a unit test project that can do fast, automated tests for us.
This will help us as we add more features. We can run the unit tests and see if the changes broke any of the pre-existing code.
Step 1: Create a new unit test project
Right-click on the SOSCSRPG solution (in Solution Explorer) and choose Add -> New Project -> Visual C# -> Test -> Unit Test Project (.NET Framework)
Name the unit test project “TestEngine”. There is no naming requirement for the solution, but I like to match my unit test project name with the name of the class library project it is testing.
Add reference in the TestEngine project to Engine project.
Delete UnitTest1.cs class – the default file created with project.
Create “ViewModels” folder in TestEngine class. This is not a .NET requirement. It’s just how I like to synchronize my tests projects with the class library project.
Step 2: Create new unit test class TestEngine\ViewModels\TestGameSession.cs
Right-click on the ViewModels folder in the TestEngine project and select Add -> New Item -> Visual C# Items -> Test -> Basic Unit Test. Name the file TestGameSession.cs
Add “using Engine.ViewModels;”, so we can access the GameSession class in the Engine.ViewModels namespace.
On line 6, notice that the unit test class has a “[TestClass]” attribute. This is how the unit test runner knows which classes have unit tests.
On lines 9 through 16 we have a unit test. Each unit tests needs to have the “[TestMethod]” attribute, to identify the function as a unit test. All unit test functions are “public void”.
On line 12 is the logic we are going to test. In this test, all we want to check is that instantiating a GameSession object will also instantiate a Player object, assign it to the CurrentPlayer property, and set the CurrentLocation to the town square.
The tests are the “Assert” statements on lines 14 and 15. These assertions are what we expect to happen, after we run the other code in the test function (in this case, only line 12)
On line 14, we assert that GameSession.CurrentPlayer should not be null. When the test runs, if this assertion is false (CurrentPlayer is null), then the test will fail.
On line 15, we assert that the CurrentLocation.Name should be “Town square”. If that assertion is not true, the test will fail.
If our assertions are correct – the values are what we expect – the test passes.
In the test on lines 18 through 27, we instantiate a GameSession object and apply 999 hit points of damage to the player. This should kill the player, causing them to move to their home location and completely heal (the things we test in the assertions on line 25 and 26).
This test exposed a problem. The GameSession.OnCurrentPlayerKilled function assumes the player was always killed by the CurrentMonster (line 293, where it displays the CurrentMonster.Name value).
We saw this type of problem before – when we had to put our RaiseMessage calls before the code that could change objects.
For now, let’s change line 293 of GameSession.cs to say, “You have been killed”. In the future, we may add other ways the player could be killed: poisons, spells, curses, etc. But, we will worry about making changes later, if we actually add those changes.
TestGameSession.cs
using Engine.ViewModels;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace TestEngine.ViewModels
{
[TestClass]
public class TestGameSession
{
[TestMethod]
public void TestCreateGameSession()
{
GameSession gameSession = new GameSession();
Assert.IsNotNull(gameSession.CurrentPlayer);
Assert.AreEqual("Town square", gameSession.CurrentLocation.Name);
}
[TestMethod]
public void TestPlayerMovesHomeAndIsCompletelyHealedOnKilled()
{
GameSession gameSession = new GameSession();
gameSession.CurrentPlayer.TakeDamage(999);
Assert.AreEqual("Home", gameSession.CurrentLocation.Name);
Assert.AreEqual(gameSession.CurrentPlayer.Level * 10, gameSession.CurrentPlayer.CurrentHitPoints);
}
}
}
GameSession.cs
using System;
using System.Linq;
using Engine.EventArgs;
using Engine.Factories;
using Engine.Models;
namespace Engine.ViewModels
{
public class GameSession : BaseNotificationClass
{
public event EventHandler<GameMessageEventArgs> OnMessageRaised;
#region Properties
private Player _currentPlayer;
private Location _currentLocation;
private Monster _currentMonster;
private Trader _currentTrader;
public World CurrentWorld { get; }
public Player CurrentPlayer
{
get { return _currentPlayer; }
set
{
if(_currentPlayer != null)
{
_currentPlayer.OnLeveledUp -= OnCurrentPlayerLeveledUp;
_currentPlayer.OnKilled -= OnCurrentPlayerKilled;
}
_currentPlayer = value;
if (_currentPlayer != null)
{
_currentPlayer.OnLeveledUp += OnCurrentPlayerLeveledUp;
_currentPlayer.OnKilled += OnCurrentPlayerKilled;
}
}
}
public Location CurrentLocation
{
get { return _currentLocation; }
set
{
_currentLocation = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasLocationToNorth));
OnPropertyChanged(nameof(HasLocationToEast));
OnPropertyChanged(nameof(HasLocationToWest));
OnPropertyChanged(nameof(HasLocationToSouth));
CompleteQuestsAtLocation();
GivePlayerQuestsAtLocation();
GetMonsterAtLocation();
CurrentTrader = CurrentLocation.TraderHere;
}
}
public Monster CurrentMonster
{
get { return _currentMonster; }
set
{
if(_currentMonster != null)
{
_currentMonster.OnKilled -= OnCurrentMonsterKilled;
}
_currentMonster = value;
if(_currentMonster != null)
{
_currentMonster.OnKilled += OnCurrentMonsterKilled;
RaiseMessage("");
RaiseMessage($"You see a {CurrentMonster.Name} here!");
}
OnPropertyChanged();
OnPropertyChanged(nameof(HasMonster));
}
}
public Trader CurrentTrader
{
get { return _currentTrader; }
set
{
_currentTrader = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasTrader));
}
}
public Weapon CurrentWeapon { get; set; }
public bool HasLocationToNorth =>
CurrentWorld.LocationAt(CurrentLocation.XCoordinate, CurrentLocation.YCoordinate + 1) != null;
public bool HasLocationToEast =>
CurrentWorld.LocationAt(CurrentLocation.XCoordinate + 1, CurrentLocation.YCoordinate) != null;
public bool HasLocationToSouth =>
CurrentWorld.LocationAt(CurrentLocation.XCoordinate, CurrentLocation.YCoordinate - 1) != null;
public bool HasLocationToWest =>
CurrentWorld.LocationAt(CurrentLocation.XCoordinate - 1, CurrentLocation.YCoordinate) != null;
public bool HasMonster => CurrentMonster != null;
public bool HasTrader => CurrentTrader != null;
#endregion
public GameSession()
{
CurrentPlayer = new Player("Scott", "Fighter", 0, 10, 10, 1000000);
if (!CurrentPlayer.Weapons.Any())
{
CurrentPlayer.AddItemToInventory(ItemFactory.CreateGameItem(1001));
}
CurrentWorld = WorldFactory.CreateWorld();
CurrentLocation = CurrentWorld.LocationAt(0, 0);
}
public void MoveNorth()
{
if(HasLocationToNorth)
{
CurrentLocation = CurrentWorld.LocationAt(CurrentLocation.XCoordinate, CurrentLocation.YCoordinate + 1);
}
}
public void MoveEast()
{
if(HasLocationToEast)
{
CurrentLocation = CurrentWorld.LocationAt(CurrentLocation.XCoordinate + 1, CurrentLocation.YCoordinate);
}
}
public void MoveSouth()
{
if(HasLocationToSouth)
{
CurrentLocation = CurrentWorld.LocationAt(CurrentLocation.XCoordinate, CurrentLocation.YCoordinate - 1);
}
}
public void MoveWest()
{
if(HasLocationToWest)
{
CurrentLocation = CurrentWorld.LocationAt(CurrentLocation.XCoordinate - 1, CurrentLocation.YCoordinate);
}
}
private void CompleteQuestsAtLocation()
{
foreach(Quest quest in CurrentLocation.QuestsAvailableHere)
{
QuestStatus questToComplete =
CurrentPlayer.Quests.FirstOrDefault(q => q.PlayerQuest.ID == quest.ID &&
!q.IsCompleted);
if(questToComplete != null)
{
if(CurrentPlayer.HasAllTheseItems(quest.ItemsToComplete))
{
// Remove the quest completion items from the player's inventory
foreach (ItemQuantity itemQuantity in quest.ItemsToComplete)
{
for(int i = 0; i < itemQuantity.Quantity; i++)
{
CurrentPlayer.RemoveItemFromInventory(CurrentPlayer.Inventory.First(item => item.ItemTypeID == itemQuantity.ItemID));
}
}
RaiseMessage("");
RaiseMessage($"You completed the '{quest.Name}' quest");
// Give the player the quest rewards
RaiseMessage($"You receive {quest.RewardExperiencePoints} experience points");
CurrentPlayer.AddExperience(quest.RewardExperiencePoints);
RaiseMessage($"You receive {quest.RewardGold} gold");
CurrentPlayer.ReceiveGold(quest.RewardGold);
foreach(ItemQuantity itemQuantity in quest.RewardItems)
{
GameItem rewardItem = ItemFactory.CreateGameItem(itemQuantity.ItemID);
RaiseMessage($"You receive a {rewardItem.Name}");
CurrentPlayer.AddItemToInventory(rewardItem);
}
// Mark the Quest as completed
questToComplete.IsCompleted = true;
}
}
}
}
private void GivePlayerQuestsAtLocation()
{
foreach(Quest quest in CurrentLocation.QuestsAvailableHere)
{
if(!CurrentPlayer.Quests.Any(q => q.PlayerQuest.ID == quest.ID))
{
CurrentPlayer.Quests.Add(new QuestStatus(quest));
RaiseMessage("");
RaiseMessage($"You receive the '{quest.Name}' quest");
RaiseMessage(quest.Description);
RaiseMessage("Return with:");
foreach(ItemQuantity itemQuantity in quest.ItemsToComplete)
{
RaiseMessage($" {itemQuantity.Quantity} {ItemFactory.CreateGameItem(itemQuantity.ItemID).Name}");
}
RaiseMessage("And you will receive:");
RaiseMessage($" {quest.RewardExperiencePoints} experience points");
RaiseMessage($" {quest.RewardGold} gold");
foreach(ItemQuantity itemQuantity in quest.RewardItems)
{
RaiseMessage($" {itemQuantity.Quantity} {ItemFactory.CreateGameItem(itemQuantity.ItemID).Name}");
}
}
}
}
private void GetMonsterAtLocation()
{
CurrentMonster = CurrentLocation.GetMonster();
}
public void AttackCurrentMonster()
{
if (CurrentWeapon == null)
{
RaiseMessage("You must select a weapon, to attack.");
return;
}
// Determine damage to monster
int damageToMonster = RandomNumberGenerator.NumberBetween(CurrentWeapon.MinimumDamage, CurrentWeapon.MaximumDamage);
if (damageToMonster == 0)
{
RaiseMessage($"You missed the {CurrentMonster.Name}.");
}
else
{
RaiseMessage($"You hit the {CurrentMonster.Name} for {damageToMonster} points.");
CurrentMonster.TakeDamage(damageToMonster);
}
if(CurrentMonster.IsDead)
{
// Get another monster to fight
GetMonsterAtLocation();
}
else
{
// Let the monster attack
int damageToPlayer = RandomNumberGenerator.NumberBetween(CurrentMonster.MinimumDamage, CurrentMonster.MaximumDamage);
if (damageToPlayer == 0)
{
RaiseMessage($"The {CurrentMonster.Name} attacks, but misses you.");
}
else
{
RaiseMessage($"The {CurrentMonster.Name} hit you for {damageToPlayer} points.");
CurrentPlayer.TakeDamage(damageToPlayer);
}
}
}
private void OnCurrentPlayerKilled(object sender, System.EventArgs eventArgs)
{
RaiseMessage("");
RaiseMessage("You have been killed.");
CurrentLocation = CurrentWorld.LocationAt(0, -1);
CurrentPlayer.CompletelyHeal();
}
private void OnCurrentMonsterKilled(object sender, System.EventArgs eventArgs)
{
RaiseMessage("");
RaiseMessage($"You defeated the {CurrentMonster.Name}!");
RaiseMessage($"You receive {CurrentMonster.RewardExperiencePoints} experience points.");
CurrentPlayer.AddExperience(CurrentMonster.RewardExperiencePoints);
RaiseMessage($"You receive {CurrentMonster.Gold} gold.");
CurrentPlayer.ReceiveGold(CurrentMonster.Gold);
foreach(GameItem gameItem in CurrentMonster.Inventory)
{
RaiseMessage($"You receive one {gameItem.Name}.");
CurrentPlayer.AddItemToInventory(gameItem);
}
}
private void OnCurrentPlayerLeveledUp(object sender, System.EventArgs eventArgs)
{
RaiseMessage($"You are now level {CurrentPlayer.Level}!");
}
private void RaiseMessage(string message)
{
OnMessageRaised?.Invoke(this, new GameMessageEventArgs(message));
}
}
}
NEXT LESSON: Lesson 12.1: Making the GameItem class more flexible
PREVIOUS LESSON: Lesson 10.6: Clean up property setters and PropertyChanged notifications