Lesson 4.2: Refactoring to Use Attack Command

With a working game engine, it’s time for some changes to make it more flexible and able to support an ever-growing list of features and functions. One large refactoring that we will make is to replace the weapon and monster attack behavior with an Attack command instead. We will learn and use the Command design pattern for these types of game actions.

First, the Command design pattern is a behavioral design pattern in which an object is used to encapsulate all information needed to perform an action or trigger an event at a later time. This information includes the method name, the object that owns the method, and values for the method parameters. And typically there is an Execute method that performs the action.

Many applications use commands to respond to user interface events (like button clicks), support multi-level undo/redo, progress indicators, and mobile app actions. We will use it to enable our GameSession view model to perform various actions… with the first one being to attack.

In this lesson and in the future, we will use both command and action interchangeably. They will mean the same thing, but the term command is easier to map to the design pattern.

Defining the Attack Command

In order the generalize the usage of commands, we are going to define an interface that our commands will use, the IAction interface. Our code will reference IAction-based commands and execute those commands. Our GameSession does not need to know the specifics of each of the commands.

We will start in the SimpleRPG.Game.Engine, and create a new folder named Actions. In that folder, let’s create the IAction interface (same as creating a class, but uses the interface keyword).

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

namespace SimpleRPG.Game.Engine.Actions
{
    public interface IAction
    {
        DisplayMessage Execute(LivingEntity actor, LivingEntity target);
    }
}

Every action will just have an Execute method that takes two parameters, the entity that is performing the action, and the target of the action. For an attack, the actor is the creature performing the attack; the target is the creature taking the resulting damage.

The Execute method returns a DisplayMessage that describes the result of the action. The DisplayMessage can then be used by our presentation layer to show the result to the user.

With the interface definition in place, let’s create our first command that implements that interface. In the same folder, let’s create the Attack action:

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

namespace SimpleRPG.Game.Engine.Actions
{
    public class Attack : IAction
    {
        private readonly GameItem _itemInUse;
        private readonly IDiceService _diceService;
        private readonly string _damageDice;

        public Attack(GameItem itemInUse, string damageDice, IDiceService? diceService = null)
        {
            _itemInUse = itemInUse ?? throw new ArgumentNullException(nameof(itemInUse));
            _diceService = diceService ?? DiceService.Instance;

            if (itemInUse.Category != GameItem.ItemCategory.Weapon)
            {
                throw new ArgumentException($"{itemInUse.Name} is not a weapon");
            }

            if (string.IsNullOrWhiteSpace(damageDice))
            {
                throw new ArgumentException("damageDice must be valid dice notation");
            }

            _damageDice = damageDice;
        }

        public DisplayMessage Execute(LivingEntity actor, LivingEntity target)
        {
            _ = actor ?? throw new ArgumentNullException(nameof(actor));
            _ = target ?? throw new ArgumentNullException(nameof(target));

            string actorName = (actor is Player) ? "You" : $"The {actor.Name.ToLower()}";
            string targetName = (target is Player) ? "you" : $"the {target.Name.ToLower()}";
            string title = (actor is Player) ? "Player Combat" : "Monster Combat";

            int damage = _diceService.Roll(_damageDice).Value;
            string message;

            if (damage == 0)
            {
                message = $"{actorName} missed {targetName}.";
            }
            else
            {
                target.TakeDamage(damage);
                message = $"{actorName} hit {targetName} for {damage} point{(damage > 1 ? "s" : "")}.";
            }

            return new DisplayMessage(title, message);
        }
    }
}

The Attack class’s constructor takes the GameItem it is attached to, the dice notation string for the damage roll, and optionally the IDiceService (so that we can make this class testable and flexible).

Then, the Execute method performs the bulk of the action. This code was copied from the GameSession.AttackCurrentMonster method — the block that deals with the player attacking the monster. Then we generalized the string creation, so that it will work for when either the player or the monster attacks.

This method:

  • validates that the actor and target are set.
  • then calculates the actor and target names and message title based on whether the player or the monster are attacking (we use “you” to represent the player).
  • then we roll the dice based on the _damageDice set in the constructor.
  • then we have the target take damage.
  • finally we return the DisplayMessage with the results of the Attack action.

To simplify DisplayMessage creation in this action, we made a minor modification to DisplayMessage to give it a constructor that just takes a title and single message string.

using System.Collections.Generic;

namespace SimpleRPG.Game.Engine.Models
{
    public class DisplayMessage
    {
        public DisplayMessage(string title, IList<string> messages)
        {
            Title = title;
            Messages = messages;
        }

        public DisplayMessage(string title, string message)
        {
            Title = title;
            Messages = new List<string> { message };
        }

        public string Title { get; } = string.Empty;

        public IList<string> Messages { get; }
    }
}

GameItem Changes

With the Attack action in place, we need to refactor GameItem to have an action. For weapons, that would be the Attack action. For minor items, there is no action. For future items, we could build other features, like a Heal action for potions.

Rather than continue to derive different items, like Weapon or Potion or whatever else, we are going to make the GameItem class more flexible by giving it a Category (which defines the type of item) and an Action (which handles whatever behavior the item has).

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

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

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

First, we have the ItemCategory enum which defines the different item types (lines #8-12). This enum is actually scoped within the GameItem class, so whenever you wish use it outside of this class, you must use the fully qualified name: GameItem.ItemCategory.Weapon.

We update the constructor and Clone method to take the ItemCategory and IAction as parameters. IAction is optional and defaults to null. If an action is not specified, then the item does not have one.

Then we define properties for: Category (line #32) and Action (line #43).

Finally, we have the PerformAction method which verifies that an action is defined on this item. Then it calls the IAction.Execute method… this method simplifies how the game engine interacts with the action.

Now, we can delete the Weapon class because we will no longer use it. Instead we will define the item with a category of Weapon. The changes to GameItem and deletion of the Weapon class will cause breaks in many places in our code, so we need to fix those up next.

Let’s start with fixing up the Inventory class. Its Weapons property needs to be rewritten to use the enum rather than the item’s type (lines #33-34):

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

            GroupedInventoryItem groupedInventoryItemToRemove =
                _backingGroupedInventory.FirstOrDefault(gi => gi.Item == item);

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

Then the ItemFactory is rewritten to handle how weapons and miscellaneous items are created.

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");

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

The ItemFactory now uses BuildMiscellaneousItem and BuildWeapon to create the item with an action, when it is required. The implementation for this class was significantly changed, so it would be useful to review it in detail. But it’s behavior is the same as before.

Refactoring GameSession and CombatComponent

We continue with our changes up our layered design and into the ViewModel. First, we need to remove any reference from the Weapon class in the IGameSession.AttackCurrentMonster definition:

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

namespace SimpleRPG.Game.Engine.ViewModels
{
    public interface IGameSession
    {
        Player CurrentPlayer { get; }

        Location CurrentLocation { get; }

        Monster? CurrentMonster { get; }

        bool HasMonster { get; }

        Trader? CurrentTrader { get; }

        MovementUnit Movement { get; }

        IList<DisplayMessage> Messages { get; }

        void OnLocationChanged(Location newLocation);

        void AttackCurrentMonster(GameItem? currentWeapon);
    }
}

Then, also update the method signature in 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(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
            var message = currentWeapon.PerformAction(CurrentPlayer, CurrentMonster);
            Messages.Add(message);

            // 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());
            }
        }
    }
}

Then we removed the player attack code and replaced it with a call to the player’s CurrentWeapon.PerformAction method (lines #87-89). Since we copied the combat action from here into the Attack action, we had to remove it from here now. We just allow the action to take care of that logic and just return us the resulting text to be displayed.

Finally, in the CombatComponent there were a few references to the Weapon class type that also need to be fixed.

<Heading Size="HeadingSize.Is5" Margin="Margin.Is2.OnY">Combat</Heading>
<Row Margin="Margin.Is2.OnY">
    <Column ColumnSize="ColumnSize.Is6.Is2.WithOffset">
        <Select id="weapons-select" TValue="int">
        @foreach (GameItem weapon in WeaponList)
        {
            <SelectItem Value="@weapon.ItemTypeID">@weapon.Name</SelectItem>
        }
        </Select>
    </Column>
    <Column ColumnSize="ColumnSize.Is2">
        <Button id="attack-monster-btn" Color="Color.Secondary" Margin="Margin.Is1"
                Disabled="@disableAttack" Clicked="OnAttackClicked">
            Attack!
        </Button>
    </Column>
</Row>

@code {
    private bool disableAttack => !WeaponList.Any() || LocationHasMonster == false;
    private int selectedWeaponId;

    [Parameter]
    public IEnumerable<GameItem> WeaponList { get; set; } = Array.Empty<GameItem>();

    [Parameter]
    public bool LocationHasMonster { get; set; } = false;

    [Parameter]
    public EventCallback<GameItem?> AttackClicked { get; set; }

    protected override void OnInitialized()
    {
        selectedWeaponId = WeaponList.Any() ? WeaponList.First().ItemTypeID : 0;
    }

    public void OnAttackClicked()
    {
        var weapon = selectedWeaponId > 0 ? WeaponList.First(f => f.ItemTypeID == selectedWeaponId) : null;
        AttackClicked.InvokeAsync(weapon);
    }
}

These changes all replace Weapon with GameItem. And when we need to differentiate, we can use GameItem.Category to figure out its type. But since we updated the Inventory.Weapons property to handle that logic, this component code is very ItemCategory agnostic already.

Once again, if we rebuild and run our game, everything should work as expected in the gameplay. We changed the underlying code, but the combat functionality should remain the same.

A Quick Word About Tests

Having all of our unit tests in place gives us confidence when making large refactoring changes like this. Since our tests cover all of the behavior of the game, we can verify that our changes continue to work as expected.

Along with the code changes above, there were a lot of code changes to tests as well. Removing a class (like Weapon) is a disruptive and breaking change. So we needed to update all of our test code as well. This may seem like a lot of work to keep the tests running, but it is necessary to keep our confidence in the system that we have written. When all of the coding changes are made and the tests all pass again, we can be confident that our changes had the expected behavior.

Sometimes as we make these types of changes, the behavior of the system and tests are expected to change. In those cases, the tests of the previous behavior will fail, but that is expected. We will fix those tests to conform to the new expected behavior and validate that it now works correctly.

Validating code refactorings is one of the big benefits of have a large, robust unit test suite that fully covers the functionality of our game. It gives us a gauge of how much work is left to complete the refactoring. And, allows us to more quickly test the game without having to manually go through each feature.

In conclusion, we defined the IAction interface that will be used for many of our commands. Then, we built that Attack action as our first command. Refactored all of our code to use the action and remove the Weapon class. And finally, changed the player attack in GameSession to use the Attack action.

In our next lesson, we’re going to refactor the Monster class and its combat operation to extend the use of the Attack action.

2 thoughts on “Lesson 4.2: Refactoring to Use Attack Command

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