Lesson 3.9: Adding Traders to the Game Engine

As we go around the village ridding it of monsters, we are starting to accumulate items (snakeskins, fangs, and rat tails). We need to do something with these before they start to clutter up our inventory… not to mention smelling up our backpack. Let’s add traders to the game so that we can sell these items and start making a living.

Traders are non-player characters (NPCs) that are found around game world locations that will buy and sell us items. The traders will provide us the Item.Price, if we sell it to them. And they will also charge us the Item.Price to buy their wares (at least for the time being).

Place Traders at Locations

1. We will define a Trader model class that derives from LivingBeing. The trader is another creature, so deriving from the base class makes sense. The power of inheritance is that Trader gets useful functionality like an Inventory from the base LivingBeing class. Let’s create the Trader class in the SimpleRPG.Game.Engine project and Models folder.

namespace SimpleRPG.Game.Engine.Models
{
    public class Trader : LivingEntity
    {
        public int Id { get; set; }
    }
}

2. Let’s create a TraderFactory (similar to the other factories in the game) that return instances of Trader objects based on the trader’s Id. In the static constructor, we create the three traders in our village. The Id is the key used in the List.First search method.

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

namespace SimpleRPG.Game.Engine.Factories
{
    internal static class TraderFactory
    {
        private static readonly List<Trader> _traders = new List<Trader>();

        static TraderFactory()
        {
            _traders.Add(CreateTrader(101, "Susan"));
            _traders.Add(CreateTrader(102, "Farmer Ted"));
            _traders.Add(CreateTrader(103, "Pete the Herbalist"));
        }

        public static Trader GetTraderById(int id) => _traders.First(t => t.Id == id);

        private static Trader CreateTrader(int id, string name)
        {
            Trader t = new Trader
            { 
                Id = id,
                Name = name,
                Level = 0,
                Gold = 100,
                MaximumHitPoints = 999,
                CurrentHitPoints = 999
            };

            t.Inventory.AddItem(ItemFactory.CreateGameItem(1001));

            return t;
        }
    }
}

3. Update the Location class to have properties for TraderHere and HasTrader, that are similar in concept and behavior to MonstersHere and HasMonster.

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

namespace SimpleRPG.Game.Engine.Models
{
    public class Location
    {
        public int XCoordinate { get; set; }

        public int YCoordinate { get; set; }

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

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

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

        public IList<MonsterEncounter> MonstersHere { get; set; } = new List<MonsterEncounter>();

        public Trader? TraderHere { get; set; } = null;

        public bool HasTrader => TraderHere != null;

        public void AddMonsterEncounter(int monsterId, int chanceOfEncountering)
        {
            if (MonstersHere.Any(m => m.MonsterId == monsterId))
            {
                // this monster has already been added to this location.
                // so overwrite the ChanceOfEncountering with the new number.
                MonstersHere.First(m => m.MonsterId == monsterId)
                            .ChanceOfEncountering = chanceOfEncountering;
            }
            else
            {
                // this monster is not already at this location, so add it.
                MonstersHere.Add(new MonsterEncounter(monsterId, chanceOfEncountering));
            }
        }

        public bool HasMonster() => MonstersHere.Any();

        public Monster GetMonster()
        {
            if (HasMonster() == false)
            {
                throw new InvalidOperationException();
            }

            // total the percentages of all monsters at this location.
            int totalChances = MonstersHere.Sum(m => m.ChanceOfEncountering);

            // Select a random number between 1 and the total (in case the total chances is not 100).
            var result = DiceService.Instance.Roll(totalChances);

            // loop through the monster list, 
            // adding the monster's percentage chance of appearing to the runningTotal variable.
            // when the random number is lower than the runningTotal, that is the monster to return.
            int runningTotal = 0;

            foreach (MonsterEncounter monsterEncounter in MonstersHere)
            {
                runningTotal += monsterEncounter.ChanceOfEncountering;

                if (result.Value <= runningTotal)
                {
                    return MonsterFactory.GetMonster(monsterEncounter.MonsterId);
                }
            }

            // If there was a problem, return the last monster in the list.
            return MonsterFactory.GetMonster(MonstersHere.Last().MonsterId);
        }
    }
}

4. Update WorldFactory to place traders at particular locations. We will place 3 traders at the farmer’s house, trading shop, and herbalist’s hut.

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

namespace SimpleRPG.Game.Engine.Factories
{
    internal static class WorldFactory
    {
        internal static World CreateWorld()
        {
            var locations = new List<Location>
            {
                new Location
                {
                    XCoordinate = -2,
                    YCoordinate = -1,
                    Name = "Farmer's Field",
                    Description = "There are rows of corn growing here, with giant rats hiding between them.",
                    ImageName = "/images/locations/FarmFields.png"
                },
                new Location
                {
                    XCoordinate = -1,
                    YCoordinate = -1,
                    Name = "Farmer's House",
                    Description = "This is the house of your neighbor, Farmer Ted.",
                    ImageName = "/images/locations/Farmhouse.png",
                    TraderHere = TraderFactory.GetTraderById(102)
                },
                new Location
                {
                    XCoordinate = 0,
                    YCoordinate = -1,
                    Name = "Home",
                    Description = "This is your home.",
                    ImageName = "/images/locations/Home.png"
                },
                new Location
                {
                    XCoordinate = -1,
                    YCoordinate = 0,
                    Name = "Trading Shop",
                    Description = "The shop of Susan, the trader.",
                    ImageName = "/images/locations/Trader.png",
                    TraderHere = TraderFactory.GetTraderById(101)
                },
                new Location
                {
                    XCoordinate = 0,
                    YCoordinate = 0,
                    Name = "Town Square",
                    Description = "You see a fountain here.",
                    ImageName = "/images/locations/TownSquare.png"
                },
                new Location
                {
                    XCoordinate = 1,
                    YCoordinate = 0,
                    Name = "Town Gate",
                    Description = "There is a gate here, protecting the town from giant spiders.",
                    ImageName = "/images/locations/TownGate.png"
                },
                new Location
                {
                    XCoordinate = 2,
                    YCoordinate = 0,
                    Name = "Spider Forest",
                    Description = "The trees in this forest are covered with spider webs.",
                    ImageName = "/images/locations/SpiderForest.png"
                },
                new Location
                {
                    XCoordinate = 0,
                    YCoordinate = 1,
                    Name = "Herbalist's Hut",
                    Description = "You see a small hut, with plants drying from the roof.",
                    ImageName = "/images/locations/HerbalistsHut.png",
                    TraderHere = TraderFactory.GetTraderById(103)
                },
                new Location
                {
                    XCoordinate = 0,
                    YCoordinate = 2,
                    Name = "Herbalist's Garden",
                    Description = "There are many plants here, with snakes hiding behind them.",
                    ImageName = "/images/locations/HerbalistsGarden.png"
                },
            };

            var newWorld = new World(locations);

            // add monsters at their particular location.
            newWorld.LocationAt(-2, -1).AddMonsterEncounter(2, 100);
            newWorld.LocationAt(2, 0).AddMonsterEncounter(3, 100);
            newWorld.LocationAt(0, 2).AddMonsterEncounter(1, 100);

            return newWorld;
        }
    }
}

5. Update the GameSession to surface the CurrentTrader when one is available – when the player moves into a location that has a TraderHere defined.

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(Weapon? currentWeapon);
    }
}
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,
                ExperiencePoints = 0,
                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();
            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.CurrentHitPoints -= damageToMonster;
                AddDisplayMessage("Player Combat", $"You hit the {CurrentMonster.Name} for {damageToMonster} points.");
            }

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

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

                CurrentPlayer.Gold += 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.CurrentHitPoints -= damageToPlayer;
                    AddDisplayMessage("Monster Combat", $"The {CurrentMonster.Name} hit you for {damageToPlayer} points.");
                }

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

                    CurrentPlayer.CurrentHitPoints = CurrentPlayer.MaximumHitPoints; // 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 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());
            }
        }
    }
}

We had to make several changes across our different engine classes, but the amount of code change wasn’t very much. We added the trader functionality to the game engine pretty easily, which helps confirm that our code design is continuing to support our features.

Create TraderViewModel

Since we are building a new UI component for the trader screen, we will also build a view model to encapsulate the functionality of that component. The view model will need to provide player and trader inventories to be shown on screen. And, it will need to handle operations for buying and selling game items between the two.

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.Gold += 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.Gold -= item.Price;
                    Trader.Inventory.RemoveItem(item);
                    Player.Inventory.AddItem(item);

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

Let’s take a detailed look at the TraderViewModel implementation:

  • We define Player and Trader properties to get access to their inventories and perform other operations.
  • We define an ErrorMessage property because we will need to show an error on the UI in certain situations… like if the player doesn’t have enough money to purchase the selected item.
  • We define an EventCallback to notify the main screen when the player’s inventory and gold has changed. This allows the main screen to update in response to that event.
  • The OnSellItem method:
    • starts with some checks of the item, Player, and Trader. The action is not allowed if any of those are null.
    • gives the player the gold value of the item.
    • removes the item from the player’s inventory.
    • then adds the item to the trader’s inventory.
    • and invokes the InventoryChanged event callback.
  • The OnBuyItem method:
    • also checks that the item, Player, and Trader elements are not null.
    • takes the price of the item from the player’s gold.
    • removes the item from the trader’s inventory.
    • adds the item to the player’s inventory.
    • and invokes the InventoryChanged event callback.
    • if the player did not have enough Gold to buy the item, an ErrorMessage is given instead.

We use the Inventory class and its AddItem and RemoveItem methods to do a lot of the heavy lifting in moving items between inventory, so we didn’t have to rebuild that logic here. And the code here only focuses on the buy and sell logic.

With the TraderViewModel complete, we can build the code and run all of the tests. If we run the game now, we will not see any changes in the game screen yet, those come in next lesson.

One thought on “Lesson 3.9: Adding Traders to the Game Engine

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