Lesson 4.1: Adding Hit Point, Gold, and Level Management

We left the Player and LivingEntity classes very open ended and allow our code to just set properties for things like hit points and experience. However, there is actually logic that needs to be run when some of these properties change. So we are going to add methods with additional logic to handle these behaviors. This will force us to refactor some of our engine code, but provides better encapsulation of our classes by moving repetitive logic into these methods.

One thing we are not doing in this lesson is making these property setters private. We still want to create code for serialization in later lessons, so we are going to figure out how that works with JSON serialization (and many serializers work better with public properties). We will make any further refactoring changes that we need at that time.

LivingEntity Changes

First, we are going to refactor the way hit points are tracked. Rather than just updating the CurrentHitPoints property, like we currently do. We are going to encapsulate some hit point behavior into the TakeDamage, Heal, and CompletelyHeal methods, with the following changes to the LivingEntity class:

using System;

namespace SimpleRPG.Game.Engine.Models
{
    public abstract class LivingEntity
    {
        public string Name { get; set; } = string.Empty;

        public int CurrentHitPoints { get; set; }

        public int MaximumHitPoints { get; set; }

        public int Gold { get; set; }

        public int Level { get; set; }

        public Inventory Inventory { get; } = new Inventory();

        public bool IsDead => CurrentHitPoints <= 0;

        public void TakeDamage(int hitPointsOfDamage)
        {
            if (hitPointsOfDamage > 0)
            {
                CurrentHitPoints -= hitPointsOfDamage;
            }
        }

        public void Heal(int hitPointsToHeal)
        {
            if (hitPointsToHeal > 0)
            {
                CurrentHitPoints += hitPointsToHeal;

                if (CurrentHitPoints > MaximumHitPoints)
                {
                    CurrentHitPoints = MaximumHitPoints;
                }
            }
        }

        public void CompletelyHeal()
        {
            CurrentHitPoints = MaximumHitPoints;
        }

        public void ReceiveGold(int amountOfGold)
        {
            if (amountOfGold > 0)
            {
                Gold += amountOfGold;
            }
        }

        public void SpendGold(int amountOfGold)
        {
            if (amountOfGold > Gold)
            {
                throw new ArgumentOutOfRangeException(nameof(amountOfGold), $"{Name} only has {Gold} gold, and cannot spend {amountOfGold} gold");
            }

            if (amountOfGold > 0)
            {
                Gold -= amountOfGold;
            }
        }
    }
}

The hit point changes involved:

  • Providing an IsDead derived property (line #19) that tests whether the CurrentHitPoints is less than or equal to 0.
  • The TakeDamage method (lines #21-27) decrements the entity’s hit points by the specified damage, as long as it’s a positive amount.
  • The Heal method (lines #29-40) increments the entity’s hit points by the specified amount (as long as it’s a positive amount as well). But it caps the total number of healed points by the MaximumHitPoints property.
  • The CompletelyHeal method (lines #42-45) heals the entity to the full amount – MaximumHitPoints.

After the hit point methods, we can look at the gold management methods:

  • The ReceiveGold method (line #47-53) increments the entity’s gold by the specified amount.
  • The SpendGold method (line #55-66) checks whether the entity has enough funds and if it doesn’t, we throw an ArgumentOutOfRange exception. If the entity has enough funds, then we decrement the gold amount.

Player Changes

We will update the Player class to manage setting the experience points and character level.

using System.Collections.Generic;

namespace SimpleRPG.Game.Engine.Models
{
    public class Player : LivingEntity
    {
        public string CharacterClass { get; set; } = string.Empty;

        public int ExperiencePoints { get; set; }

        public IList<QuestStatus> Quests { get; set; } = new List<QuestStatus>();

        public void AddExperience(int experiencePoints)
        {
            if (experiencePoints > 0)
            {
                ExperiencePoints += experiencePoints;
                SetLevelAndMaximumHitPoints();
            }
        }

        private void SetLevelAndMaximumHitPoints()
        {
            int originalLevel = Level;

            Level = (ExperiencePoints / 100) + 1;

            if (Level != originalLevel)
            {
                MaximumHitPoints = Level * 10;
            }
        }
    }
}

The AddExperience method first increments the experience points by the specified amount. Then it has logic to set the level based on 1 level per 100 experience points. And when the player achieves a new level, we update the MaximumHitPoints for the player as well — the MaximumHitPoints calculation is 10 hit points per level.

With these changes, we have completed the functionality that we wanted. Now we need to update the view models to use these new methods (rather than direct access to the properties).

View Model Changes

First, we have two minor changes to the TraderViewModel class:

using Microsoft.AspNetCore.Components;
using SimpleRPG.Game.Engine.Models;
using System;

namespace SimpleRPG.Game.Engine.ViewModels
{
    public class TraderViewModel
    {
        public Trader? Trader { get; set; } = null;

        public Player? Player { get; set; } = null;

        public string ErrorMessage { get; private set; } = string.Empty;

        public EventCallback InventoryChanged { get; set; }

        public void OnSellItem(GameItem item)
        {
            _ = item ?? throw new ArgumentNullException(nameof(item));

            if (Player != null && Trader != null)
            {
                Player.ReceiveGold(item.Price);
                Trader.Inventory.AddItem(item);
                Player.Inventory.RemoveItem(item);

                InventoryChanged.InvokeAsync(null);
            }
        }

        public void OnBuyItem(GameItem item)
        {
            _ = item ?? throw new ArgumentNullException(nameof(item));

            if (Player != null && Trader != null)
            {
                ErrorMessage = string.Empty;
                if (Player.Gold >= item.Price)
                {
                    Player.SpendGold(item.Price);
                    Trader.Inventory.RemoveItem(item);
                    Player.Inventory.AddItem(item);

                    InventoryChanged.InvokeAsync(null);
                }
                else
                {
                    ErrorMessage = "Error: you do not have enough gold.";
                }
            }
        }
    }
}

During OnSellItem, we use the Player.ReceiveGold method to get the sales proceeds. During OnBuyItem, we use the Player.SpendGold method to remove that amount from the player.

Then we need to make similar changes to the GameSession class:

using SimpleRPG.Game.Engine.Factories;
using SimpleRPG.Game.Engine.Models;
using SimpleRPG.Game.Engine.Services;
using System;
using System.Collections.Generic;
using System.Linq;

namespace SimpleRPG.Game.Engine.ViewModels
{
    public class GameSession : IGameSession
    {
        private readonly World _currentWorld;
        private readonly IDiceService _diceService;
        private readonly int _maximumMessagesCount = 100;

        public Player CurrentPlayer { get; private set; }

        public Location CurrentLocation { get; private set; }

        public Monster? CurrentMonster { get; private set; }

        public bool HasMonster => CurrentMonster != null;

        public Trader? CurrentTrader { get; private set; }

        public MovementUnit Movement { get; private set; }

        public IList<DisplayMessage> Messages { get; } = new List<DisplayMessage>();

        public GameSession(int maxMessageCount)
            : this()
        {
            _maximumMessagesCount = maxMessageCount;
        }

        public GameSession(IDiceService? diceService = null)
        {
            _diceService = diceService ?? DiceService.Instance;

            CurrentPlayer = new Player
            {
                Name = "DarthPedro",
                CharacterClass = "Fighter",
                CurrentHitPoints = 10,
                MaximumHitPoints = 10,
                Gold = 1000,
                Level = 1
            };

            _currentWorld = WorldFactory.CreateWorld();

            Movement = new MovementUnit(_currentWorld);
            CurrentLocation = Movement.CurrentLocation;
            GetMonsterAtCurrentLocation();

            if (!CurrentPlayer.Inventory.Weapons.Any())
            {
                CurrentPlayer.Inventory.AddItem(ItemFactory.CreateGameItem(1001));
            }
        }

        public void OnLocationChanged(Location newLocation)
        {
            _ = newLocation ?? throw new ArgumentNullException(nameof(newLocation));

            CurrentLocation = newLocation;
            Movement.UpdateLocation(CurrentLocation);
            GetMonsterAtCurrentLocation();
            CompleteQuestsAtLocation();
            GetQuestsAtLocation();
            CurrentTrader = CurrentLocation.TraderHere;
        }

        public void AttackCurrentMonster(Weapon? currentWeapon)
        {
            if (CurrentMonster is null)
            {
                return;
            }

            if (currentWeapon is null)
            {
                AddDisplayMessage("Combat Warning", "You must select a weapon, to attack.");
                return;
            }

            // Determine damage to monster
            int damageToMonster = _diceService.Roll(currentWeapon.DamageRoll).Value;

            if (damageToMonster == 0)
            {
                AddDisplayMessage("Player Combat", $"You missed the {CurrentMonster.Name}.");
            }
            else
            {
                CurrentMonster.TakeDamage(damageToMonster);
                AddDisplayMessage("Player Combat", $"You hit the {CurrentMonster.Name} for {damageToMonster} points.");
            }

            // If monster if killed, collect rewards and loot
            if (CurrentMonster.IsDead)
            {
                var messageLines = new List<string>();
                messageLines.Add($"You defeated the {CurrentMonster.Name}!");

                CurrentPlayer.AddExperience(CurrentMonster.RewardExperiencePoints);
                messageLines.Add($"You receive {CurrentMonster.RewardExperiencePoints} experience points.");

                CurrentPlayer.ReceiveGold(CurrentMonster.Gold);
                messageLines.Add($"You receive {CurrentMonster.Gold} gold.");

                foreach (GameItem item in CurrentMonster.Inventory.Items)
                {
                    CurrentPlayer.Inventory.AddItem(item);
                    messageLines.Add($"You received {item.Name}.");
                }

                AddDisplayMessage("Monster Defeated", messageLines);

                // Get another monster to fight
                GetMonsterAtCurrentLocation();
            }
            else
            {
                // If monster is still alive, let the monster attack
                int damageToPlayer = _diceService.Roll(CurrentMonster.DamageRoll).Value;

                if (damageToPlayer == 0)
                {
                    AddDisplayMessage("Monster Combat", "The monster attacks, but misses you.");
                }
                else
                {
                    CurrentPlayer.TakeDamage(damageToPlayer);
                    AddDisplayMessage("Monster Combat", $"The {CurrentMonster.Name} hit you for {damageToPlayer} points.");
                }

                // If player is killed, move them back to their home.
                if (CurrentPlayer.IsDead)
                {
                    AddDisplayMessage("Player Defeated", $"The {CurrentMonster.Name} killed you.");

                    CurrentPlayer.CompletelyHeal();  // Completely heal the player
                    this.OnLocationChanged(_currentWorld.LocationAt(0, -1));  // Return to Player's home
                }
            }
        }

        private void GetMonsterAtCurrentLocation()
        {
            CurrentMonster = CurrentLocation.HasMonster() ? CurrentLocation.GetMonster() : null;

            if (CurrentMonster != null)
            {
                AddDisplayMessage("Monster Encountered:", $"You see a {CurrentMonster.Name} here!");
            }
        }

        private void GetQuestsAtLocation()
        {
            foreach (Quest quest in CurrentLocation.QuestsAvailableHere)
            {
                if (!CurrentPlayer.Quests.Any(q => q.PlayerQuest.Id == quest.Id))
                {
                    CurrentPlayer.Quests.Add(new QuestStatus(quest));

                    var messageLines = new List<string>
                    {
                        quest.Description,
                        "Items to complete the quest:"
                    };

                    foreach (ItemQuantity q in quest.ItemsToComplete)
                    {
                        messageLines.Add($"    {ItemFactory.CreateGameItem(q.ItemId).Name} (x{q.Quantity})");
                    }

                    messageLines.Add("Rewards for quest completion:");
                    messageLines.Add($"   {quest.RewardExperiencePoints} experience points");
                    messageLines.Add($"   {quest.RewardGold} gold");
                    foreach (ItemQuantity itemQuantity in quest.RewardItems)
                    {
                        messageLines.Add($"   {itemQuantity.Quantity} {ItemFactory.CreateGameItem(itemQuantity.ItemId).Name} (x{itemQuantity.Quantity})");
                    }

                    AddDisplayMessage($"Quest Added - {quest.Name}", messageLines);
                }
            }
        }

        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.Inventory.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.Inventory.RemoveItem(
                                    CurrentPlayer.Inventory.Items.First(
                                        item => item.ItemTypeID == itemQuantity.ItemId));
                            }
                        }

                        // give the player the quest rewards
                        var messageLines = new List<string>();
                        CurrentPlayer.AddExperience(quest.RewardExperiencePoints);
                        messageLines.Add($"You receive {quest.RewardExperiencePoints} experience points");

                        CurrentPlayer.ReceiveGold(quest.RewardGold);
                        messageLines.Add($"You receive {quest.RewardGold} gold");

                        foreach (ItemQuantity itemQuantity in quest.RewardItems)
                        {
                            GameItem rewardItem = ItemFactory.CreateGameItem(itemQuantity.ItemId);

                            CurrentPlayer.Inventory.AddItem(rewardItem);
                            messageLines.Add($"You receive a {rewardItem.Name}");
                        }

                        AddDisplayMessage($"Quest Completed - {quest.Name}", messageLines);

                        // mark the quest as completed
                        questToComplete.IsCompleted = true;
                    }
                }
            }
        }

        private void AddDisplayMessage(string title, string message) =>
            AddDisplayMessage(title, new List<string> { message });

        private void AddDisplayMessage(string title, IList<string> messages)
        {
            var message = new DisplayMessage(title, messages);
            this.Messages.Insert(0, message);

            if (Messages.Count > _maximumMessagesCount)
            {
                Messages.Remove(Messages.Last());
            }
        }
    }
}

For the first change in the attack method, call AddExperience and ReceiveGold when the monster is defeated.

Then, if the player is defeated, we use the CompletelyHeal method to reset the player’s hit points.

Finally when we complete a quest, we use AddExperience and ReceiveGold again to give the player the rewards from the quest.

These refactorings in the GameSession are small one-line changes, but they change the behavior of experience, hit points, and gold in our system. We were able to easily add more logic to these aspects of the game when we need to.

Unit Testing with [Theory]

The bulk of our unit tests until this point have used the [Fact] attribute from xUnit. This runs one test and its validations. However, when we want to test with several variations, rather than write each individual test and just varying the data and expected values, we can use the [Theory] and [InlineData] attributes from xUnit to simplify test creation.

Let’s take a look a one of the tests we wrote for this lesson in the PlayerTests.cs file:

using SimpleRPG.Game.Engine.Models;
using System;
using System.Collections.Generic;
using Xunit;

namespace SimpleRPG.Game.Engine.Tests.Models
{
    public class PlayerTests
    {
...
        [Theory]
        [InlineData(10, 4, 6)]
        [InlineData(4, 8, -4)]
        [InlineData(6, -2, 6)]
        public void TakeDamage(int currHP, int damage, int expected)
        {
            // arrange
            var p = new Player
            {
                Name = "Test",
                Level = 1,
                CurrentHitPoints = currHP,
                MaximumHitPoints = 10
            };

            // act
            p.TakeDamage(damage);

            // assert
            Assert.Equal(expected, p.CurrentHitPoints);
            Assert.Equal(10, p.MaximumHitPoints);
        }
...
    }
}

This test method validates the TakeDamage method works as expected. Notice the structure of this test as opposed to our other unit test methods:

  • First, the test method has parameters for the player’s current hit points, the damage value, and the expected result of hit points. These parameters are used in setting up the test, running the TakeDamage action, and asserting the expected value.
  • Then the test uses the [Theory] attribute to tell xUnit that this is actually a set of tests, rather than a single one.
  • Finally, the [InlineData] attribute corresponds to each individual test case and provides the data for each one. The set of parameters in this attribute correspond to the parameters passed into the test method.
  • With all of this in place, this test method actually runs 3 times with the different data values, and validates that all three tests calculate the expected hit point value.

Using this xUnit feature lets you write a lot of test variations without needing to duplicate test code. This works well when dealing with mathematic or highly formulaic tests.

In conclusion

We can now build and run our game again. We will notice that the player in initialized correctly with gold, level, and default hit points. As we play the game, kill some monsters, and gain experience and gold, we can see the behavior we coded in this lesson — the player will accumulate experience, move to level 2, and get a new maximum hit points total.

Fig 1 – Game screen with level and hit points

We will look at incorporating game actions in our next lessons…

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s