Lesson 3.12: Completing Quests

With our quests in place, the player will be able to navigate the world, find snakes, and attack them to get five fangs. These are the items required to complete our first quest. Once we do all of the work, we will need to return to the herbalist to collect our rewards.

At this point, there’s no logic in our game engine to deal with completing the quest. We need to add it. We need the code to verify that all of the required items are available in the player’s inventory, then remove all of the required items, and finally give the player their rewards: gold, experience points, and items. This will be a pretty straight-forward change.

First, we need to update the Inventory class to include a method (HasAllTheseItems) that checks that all of the required items are found in the inventory item list.

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 IEnumerable<GameItem> Weapons => _backingInventory.Where(i => i is Weapon);

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

The HasAllTheseItems method accepts a list of ItemQuantity objects and looks through the player’s inventory.

If the count of items is less than the number required in the parameter, the method returns false – the player does not have all the required items. If the player has a large enough quantity, for all the items passed into this method, then it returns true.

Then, we need to make some modifications to the GameSession view model to check for completed quests when moving to a new location.

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();
            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.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 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.ExperiencePoints += quest.RewardExperiencePoints;
                        messageLines.Add($"You receive {quest.RewardExperiencePoints} experience points");

                        CurrentPlayer.Gold += 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());
            }
        }
    }
}

Looking more closely through these changes:

  1. We add a call to CompleteQuestsAtLocation in the OnLocationChanged event handler, just prior to looking for new quests. This will allow us to chain quests together, so that completing one quest could trigger a new quest. We have not built that functionality yet, but it will be possible to do in the future.
  2. We updated the GetQuestsAtLocation method to show more details about the newly found quest — to include all of the rewards available from the quest: gold, experience points, and items.
  3. We created the CompleteQuestsAtLocation method to validate the required items are available in the player’s inventory and then complete the quest.
    • We loop through, using the foreach command, all of the quests available at this location. If the location does not have any quests, we will skip the loop altogether.
    • Then for each location quest, we see if the player has been given the quest that may be completed (with the corresponding id and has not previous been completed). We retrieve that quest, or questToComplete is set to null.
    • If there is no corresponding quest in the player’s list, we just end here.
    • Otherwise, we check of the player has all of the items to complete this quest.
    • Again if the player does not have all of the pre-requisites, then we end the check for this quest.
    • Otherwise, we have completed the quest and we need to process the items and rewards.
      • Remove all of the required items to complete the quest from the player’s inventory.
      • Increment the player’s experience points by the quest’s reward experience.
      • Increment the player’s gold by the quest’s reward gold.
      • Add the quest’s reward items to the player’s inventory.
    • Then we display a message with the with all of the changes that were made.
    • Finally, we set the QuestStatus.IsCompleted to true.

With this new logic in place, we are ready to test out the quest functionality in our game. Let’s rebuild the game and run it again.

First, move to the herbalist’s hut to get the initial quest.

Fig 1 – Get initial quest

Then, go to the herbalist’s garden and fight some snakes until you have the required items.

Fig 2 – Battle snakes to get fangs

Finally, return to the herbalist’s hut to turn in your snake fangs for the reward.

Fig 3 – Complete the initial quest

We are now able to run through quests, fight monsters to get their items, and use them to complete quests. Our game is really coming together.

One thought on “Lesson 3.12: Completing Quests

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 )

Facebook photo

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

Connecting to %s