Lesson 4.3 Monsters Attack with Weapons

Since we moved the player’s weapon to use the Attack command/action, it makes sense to do the same with the monsters too. The monsters are technically fighting with their natural weapons, be it a bite or claw attack. But if we want our hero to eventually fight other humanoids, then those would also fight with weapons. So in this lesson we’re going to refactor our monster and combat code to use and attack with weapons as well.

These changes will be similar to the player changes in the previous lesson, but will focus on the Monster class and the monster portion of the combat in GameEngine. These changes show us some of the flexibility of using the Attack command.

Model and Factory Changes

First, we are going to remove the DamageRoll property from the Monster class. This class will no longer know anything about attack abilities or data.

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

        public int RewardExperiencePoints { get; set; }
    }
}

Next, we will add a CurrentWeapon property to the LivingEntity class (line #19). Anything deriving from this class (including Monster and Player) will be able to have a selected weapon that they are using. The CurrentWeapon can also be null for creatures that do not carry weapons and unable to attack.

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 GameItem? CurrentWeapon { get; set; }

        public bool HasCurrentWeapon => CurrentWeapon != null;

        public bool IsDead => CurrentHitPoints <= 0;

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

        public DisplayMessage UseCurrentWeaponOn(LivingEntity target)
        {
            if (CurrentWeapon is null)
            {
                throw new InvalidOperationException("CurrentWeapon cannot be null.");
            }

            return CurrentWeapon.PerformAction(this, target);
        }

        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;
            }
        }
    }
}

We also added a helper method, UseCurrentWeaponOn (lines #33-41), which encapsulates the use of the current weapon. It validates that this LivingEntity does have a CurrentWeapon prior to calling the GameItem.PerformAction method. If there is no CurrentWeapon, but we are still try to use it, then this method throws an InvalidOperationException.

Then, we need to update the factory classes to create monsters with a CurrentWeapon. We will update the ItemFactory to provide the game engine with natural weapons for our monsters (in lines #18-20, we create fangs and claws for them).

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

namespace SimpleRPG.Game.Engine.Factories
{
    internal static class ItemFactory
    {
        private static readonly List<GameItem> _standardGameItems = new List<GameItem>();

        static ItemFactory()
        {
            BuildWeapon(1001, "Pointy Stick", 1, "1d2");
            BuildWeapon(1002, "Rusty Sword", 5, "1d3");

            BuildWeapon(1501, "Snake fangs", 0, "1d2");
            BuildWeapon(1502, "Rat claws", 0, "1d2");
            BuildWeapon(1503, "Spider fangs", 0, "1d4");

            BuildMiscellaneousItem(9001, "Snake fang", 1);
            BuildMiscellaneousItem(9002, "Snakeskin", 2);
            BuildMiscellaneousItem(9003, "Rat tail", 1);
            BuildMiscellaneousItem(9004, "Rat fur", 2);
            BuildMiscellaneousItem(9005, "Spider fang", 1);
            BuildMiscellaneousItem(9006, "Spider silk", 2);
        }

        public static GameItem CreateGameItem(int itemTypeID)
        {
            var standardItem = _standardGameItems.First(i => i.ItemTypeID == itemTypeID);

            return standardItem.Clone();
        }

        private static void BuildMiscellaneousItem(int id, string name, int price)
        {
            _standardGameItems.Add(new GameItem(id, GameItem.ItemCategory.Miscellaneous, name, price));
        }

        private static void BuildWeapon(int id, string name, int price, string damageDice)
        {
            var weapon = new GameItem(id, GameItem.ItemCategory.Weapon, name, price, true);
            weapon.Action = new Attack(weapon, damageDice);

            _standardGameItems.Add(weapon);
        }
    }
}

Next, we need to give the monsters these weapons when we create them in the MonsterFactory. The additional code here sets the CurrentWeapon for each of our three dreaded foes.

using SimpleRPG.Game.Engine.Models;
using SimpleRPG.Game.Engine.Services;
using System;

namespace SimpleRPG.Game.Engine.Factories
{
    internal static class MonsterFactory
    {
        private static readonly IDiceService _service = DiceService.Instance;

        public static Monster GetMonster(int monsterID)
        {
            switch (monsterID)
            {
                case 1:
                    Monster snake = new Monster
                    {
                        Name = "Snake",
                        ImageName = "/images/monsters/snake.png",
                        CurrentHitPoints = 4,
                        MaximumHitPoints = 4,
                        RewardExperiencePoints = 5,
                        Gold = 1,
                    };

                    snake.CurrentWeapon = ItemFactory.CreateGameItem(1501);
                    AddLootItem(snake, 9001, 25);
                    AddLootItem(snake, 9002, 75);
                    return snake;

                case 2:
                    Monster rat = new Monster
                    {
                        Name = "Rat",
                        ImageName = "/images/monsters/rat.png",
                        CurrentHitPoints = 5,
                        MaximumHitPoints = 5,
                        RewardExperiencePoints = 5,
                        Gold = 1,
                    };

                    rat.CurrentWeapon = ItemFactory.CreateGameItem(1502);
                    AddLootItem(rat, 9003, 25);
                    AddLootItem(rat, 9004, 75);
                    return rat;

                case 3:
                    Monster giantSpider = new Monster
                    {
                        Name ="Giant Spider",
                        ImageName = "/images/monsters/giant-spider.png",
                        CurrentHitPoints = 10,
                        MaximumHitPoints = 10,
                        RewardExperiencePoints = 10,
                        Gold = 3,
                    };

                    giantSpider.CurrentWeapon = ItemFactory.CreateGameItem(1503);
                    AddLootItem(giantSpider, 9005, 25);
                    AddLootItem(giantSpider, 9006, 75);
                    return giantSpider;

                default:
                    throw new ArgumentOutOfRangeException(nameof(monsterID));
            }
        }

        private static void AddLootItem(Monster monster, int itemID, int percentage)
        {
            if (_service.Roll("1d100").Value <= percentage)
            {
                monster.Inventory.AddItem(ItemFactory.CreateGameItem(itemID));
            }
        }
    }
}

Note that we also removed the DamageRoll property setter from each monster created.

Refactoring GameSession AttackCurrentMonster

The last piece we need to change now is the GameSession view model. Along with changing how monster attacks are handled, we are also going to refactor the internals of the AttackCurrentMonster method. As we’ve evolved this method and the combat system, this has become a large method that is hard to read at times. So, we are going to break it up into its parts: player attacks, handle monster death, monster attacks, handle player death. Each of these discrete steps will be factored out into their own private methods. This will make reading and maintaining the code easier in the future.

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(GameItem? currentWeapon)
        {
            if (CurrentMonster is null)
            {
                return;
            }

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

            // player acts monster with weapon
            CurrentPlayer.CurrentWeapon = currentWeapon;
            var message = CurrentPlayer.UseCurrentWeaponOn(CurrentMonster);
            AddDisplayMessage(message);

            // if monster is killed, collect rewards and loot
            if (CurrentMonster.IsDead)
            {
                OnCurrentMonsterKilled(CurrentMonster);

                // get another monster to fight
                GetMonsterAtCurrentLocation();
            }
            else
            {
                // if monster is still alive, let the monster attack
                message = CurrentMonster.UseCurrentWeaponOn(CurrentPlayer);
                AddDisplayMessage(message);

                // if player is killed, move them back to their home and heal.
                if (CurrentPlayer.IsDead)
                {
                    OnCurrentPlayerKilled(CurrentMonster);
                }
            }
        }

        private void OnCurrentPlayerKilled(Monster currentMonster)
        {
            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 OnCurrentMonsterKilled(Monster currentMonster)
        {
            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);
        }

        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)
        {
            AddDisplayMessage(new DisplayMessage(title, messages));
        }

        private void AddDisplayMessage(DisplayMessage message)
        {
            this.Messages.Insert(0, message);

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

First, let’s look at the individual new methods and what they are responsible for:

  • OnCurrentPlayerKilled (lines #114-120) – this method informs the player that they were killed, heals them completely, and resets the location to our home location.
  • OnCurrentMonsterKilled (lines #122-140) – this method informs the player that the monster was killed, adds to their experience and gold, and gives them the monster’s inventory items.
  • AddDisplayMethod override (lines #240-248) – this new override adds a DisplayMessage to our Messages list (taking in a fully created DisplayMessage). This override was needed to add messages from the Attack action return value.

With those helper methods in place, the new AttackCurrentMonster (lines #87-111) method has been simplified. The outline of the method is exactly what we described earlier:

  1. Player attacks and deals damage to monster.
  2. If the monster dies, then call the method that handles the monster being killed.
  3. If the monster is still alive, then it attacks the player.
  4. If the player dies, then call the method to deal with the player being killed.

I think we can all agree that this code is much more readable, and we can easily understand its intent. And now our player and monsters use the same action to attack one another.

If we build and run our game again, we can explore our game world and fight some monsters. The behavior to the game should not have changed significantly.

Bug Fixes

We introduced a couple of bugs into the codebase in the last few lessons, so it’s time to clean them up as well.

1. In lesson 4.2, we extended the GameItem to have an Action property and initialize an optional Action in its constructor. However, we never added that capability to the Clone method, so when weapon items were cloned, they would be created without their corresponding attack action. We will fix this in the Clone method by adding the current Action property into the clone’s constructor.

using SimpleRPG.Game.Engine.Actions;
using System;

namespace SimpleRPG.Game.Engine.Models
{
    public class GameItem
    {
        public enum ItemCategory
        {
            Miscellaneous,
            Weapon
        }

        public static readonly GameItem Empty = new GameItem();

        public GameItem(int itemTypeID, ItemCategory category, string name, int price, bool isUnique = false, IAction? action = null)
        {
            ItemTypeID = itemTypeID;
            Category = category;
            Name = name;
            Price = price;
            IsUnique = isUnique;
            Action = action;
        }

        public GameItem()
        {
        }

        public int ItemTypeID { get; set; }
        
        public ItemCategory Category { get; set; }

        public string Name { get; set; } = string.Empty;

        public int Price { get; set; }

        public bool IsUnique { get; set; }

        public IAction? Action { get; set; }

        public virtual GameItem Clone() =>
            new GameItem(ItemTypeID, Category, Name, Price, IsUnique, Action);

        public DisplayMessage PerformAction(LivingEntity actor, LivingEntity target)
        {
            if (Action is null)
            {
                throw new InvalidOperationException("CurrentWeapon.Action cannot be null");
            }

            return Action.Execute(actor, target);
        }
    }
}

2. When we implemented quest completion in Lesson 3.12, we exposed a subtle bug in the removal of quest items from the player’s inventory. When removing grouped items, we were performing an equality check, but only one element in that group is the same instance as the deleted item. Reference equality is not a good way of dealing with grouped items, because only one item was ever being removed from the grouped item count. What we really needed to check was whether the GameItem.Id‘s were equal and decrement the grouped item based on that. To fix the bug, we will make the following change to the Inventory class:

using System;
using System.Collections.Generic;
using System.Linq;

namespace SimpleRPG.Game.Engine.Models
{
    public class Inventory
    {
        private readonly List<GameItem> _backingInventory = new List<GameItem>();
        private readonly List<GroupedInventoryItem> _backingGroupedInventory = new List<GroupedInventoryItem>();

        public Inventory(IEnumerable<GameItem> items)
        {
            if (items == null)
            {
                return;
            }

            foreach (GameItem item in items)
            {
                AddItem(item);
            }
        }

        public Inventory()
        {
        }

        public IReadOnlyList<GameItem> Items => _backingInventory.AsReadOnly();

        public IReadOnlyList<GroupedInventoryItem> GroupedItems => _backingGroupedInventory.AsReadOnly();

        public IList<GameItem> Weapons =>
            Items.Where(i => i.Category == GameItem.ItemCategory.Weapon).ToList();

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

            _backingInventory.Add(item);

            if (item.IsUnique)
            {
                _backingGroupedInventory.Add(new GroupedInventoryItem { Item = item, Quantity = 1 });
            }
            else
            {
                if (_backingGroupedInventory.All(gi => gi.Item.ItemTypeID != item.ItemTypeID))
                {
                    _backingGroupedInventory.Add(new GroupedInventoryItem { Item = item, Quantity = 0 });
                }

                _backingGroupedInventory.First(gi => gi.Item.ItemTypeID == item.ItemTypeID).Quantity++;
            }
        }

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

            _backingInventory.Remove(item);

            if (item.IsUnique == false)
            {
                GroupedInventoryItem groupedInventoryItemToRemove =
                    _backingGroupedInventory.FirstOrDefault(gi => gi.Item.ItemTypeID == item.ItemTypeID);

                if (groupedInventoryItemToRemove != null)
                {
                    if (groupedInventoryItemToRemove.Quantity == 1)
                    {
                        _backingGroupedInventory.Remove(groupedInventoryItemToRemove);
                    }
                    else
                    {
                        groupedInventoryItemToRemove.Quantity--;
                    }
                }
            }
        }

        public bool HasAllTheseItems(IEnumerable<ItemQuantity> items)
        {
            return items.All(item => Items.Count(i => i.ItemTypeID == item.ItemId) >= item.Quantity);
        }
    }
}

This behavior was a hole in our unit test coverage, so we also added a couple of tests to validate the new desired effect. It is important to add unit tests when we find bugs in our code. It means that we were missing some cases from our tests or misunderstood how a feature should work. With every bug fix, we should create a test that exposes the erroneous behavior, and that test should fail. Then, we can fix the bug in our code. When we run the tests again, they should now pass. This way we know that we fixed the bug and didn’t introduce other bugs with the change.

Having unit tests for bugs in our testbed also ensures that we don’t regress or break this behavior again. Building tests incrementally (as we introduce code changes) like this is a great way to add tests to legacy code that doesn’t already have them. But regressions (where we break existing functionality) is one of the toughest errors to explain. Managers and customers really don’t like that. But with the right tests in place, we can minimize introducing regression bugs into our code.

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