Lesson 99.2: Replace random number library

DarthPedro has a cool random number library that’s available as a NuGet package. In this lesson, we’re going to add it to the game, remove the existing random number class, and update the game to use the new library’s format for describing the random number range – which looks more like the old Dungeons & Dragons format, like “3d6” to roll three six-sided dice.

We’ve also upgraded to .NET Framework 4.72.

 

You can view all the changed files in this pull request on GitHub: https://github.com/ScottLilly/SOSCSRPG/pull/15/files

 

 

Lesson Steps

 

Step 1: Add the D20Tek NuGet packages to the Engine project.

Open the solution, right-click on the solution in Solution Explorer, and select “Manage NuGet Packages for Solution…”.

Click on the “Browse” link, then add these two packages to the Engine project: D20Tek.Common.Net47 and D20Tek.DiceNotation.Net47.

 

Step 2: Create \Engine\Services\DiceService.cs and IDiceService.cs

This class will replace our RandomNumberGenerator class.

This class uses the Singleton design pattern, for us to only ever have a single instance of it. There are several configuration options we can use, but we’re mostly just going to use the “Roll” functions on line 56 and 58.

These roll functions let us pass in how many sides a die has and how many times we want to roll it. We can pass that in as integer parameters for the version of Roll() on line 58. Or we can pass it in as a string, like “1d5” to roll a five-sided die once, to the version on line 56.

 

We also have an interface class for DiceService. This is used as the datatype for properties and variables in the DiceService class. We could also use the interface if we ever want to create a mock DiceService for a unit test.

 

DiceService.cs

using D20Tek.DiceNotation;
using D20Tek.DiceNotation.DieRoller;
using System;

namespace Engine.Services
{
    public class DiceService : IDiceService
    {
        private static readonly IDiceService _instance = new DiceService();

        /// <summary>
        /// Make constructor private to implement singletone pattern.
        /// </summary>
        private DiceService()
        {
        }

        /// <summary>
        /// Static singleton property
        /// </summary>
        public static IDiceService Instance => _instance;

        //--- IDiceService implementation

        public IDice Dice { get; } = new Dice();

        public IDieRoller DieRoller { get; private set; } = new RandomDieRoller();

        public IDiceConfiguration Configuration => Dice.Configuration;

        public IDieRollTracker RollTracker { get; private set; } = null;

        public void Configure(RollerType rollerType, bool enableTracker = false, int constantValue = 1)
        {
            RollTracker = enableTracker ? new DieRollTracker() : null;

            switch (rollerType)
            {
                case RollerType.Random:
                    DieRoller = new RandomDieRoller(RollTracker);
                    break;
                case RollerType.Crypto:
                    DieRoller = new CryptoDieRoller(RollTracker);
                    break;
                case RollerType.MathNet:
                    DieRoller = new MathNetDieRoller(RollTracker);
                    break;
                case RollerType.Constant:
                    DieRoller = new ConstantDieRoller(constantValue);
                    break;
                default:
                    throw new ArgumentOutOfRangeException(nameof(rollerType));
            }
        }

        public DiceResult Roll(string diceNotation) => Dice.Roll(diceNotation, DieRoller);

        public DiceResult Roll(int sides, int numDice = 1, int modifier = 0)
        {
            DiceResult result = Dice.Dice(sides, numDice).Constant(modifier).Roll(DieRoller);
            Dice.Clear();

            return result;
        }
    }
}

 

IDiceService.cs

using D20Tek.DiceNotation;
using D20Tek.DiceNotation.DieRoller;

namespace Engine.Services
{
    public interface IDiceService
    {
        IDice Dice { get; }

        IDiceConfiguration Configuration { get; }

        IDieRollTracker RollTracker { get; }

        void Configure(RollerType rollerType, bool enableTracker = false, int constantValue = 1);

        DiceResult Roll(string diceNotation);

        DiceResult Roll(int sides, int numDice = 1, int modifier = 0);
    }

    public enum RollerType
    {
        Random = 0,
        Crypto = 1,
        MathNet = 2,
        Constant = 3
    }
}

 

Step 3: Delete \Engine\RandomNumberGenerator.cs

Not much to say here, other than just delete this class from the project.

 

Step 4: Modify \Engine\Models\Location.cs

Add “using Engine.Services;”, so we have access to the new DiceService class.

Then, change line 66 to get the random number that determines the monster at the location.

 

Location.cs

using System.Collections.Generic;
using System.Linq;
using Engine.Factories;
using Engine.Services;
using Newtonsoft.Json;

namespace Engine.Models
{
    public class Location
    {
        public int XCoordinate { get; }
        public int YCoordinate { get; }
        [JsonIgnore]
        public string Name { get; }
        [JsonIgnore]
        public string Description { get; }
        [JsonIgnore]
        public string ImageName { get; }

        [JsonIgnore]
        public List<Quest> QuestsAvailableHere { get; } = new List<Quest>();

        [JsonIgnore]
        public List<MonsterEncounter> MonstersHere { get; } =
            new List<MonsterEncounter>();

        [JsonIgnore]
        public Trader TraderHere { get; set; }

        public Location(int xCoordinate, int yCoordinate, string name, string description, string imageName)
        {
            XCoordinate = xCoordinate;
            YCoordinate = yCoordinate;
            Name = name;
            Description = description;
            ImageName = imageName;
        }

        public void AddMonster(int monsterID, int chanceOfEncountering)
        {
            if(MonstersHere.Exists(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 Monster GetMonster()
        {
            if(!MonstersHere.Any())
            {
                return null;
            }

            // 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).
            int randomNumber = DiceService.Instance.Roll(totalChances, 1).Value;

            // 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(randomNumber <= runningTotal)
                {
                    return MonsterFactory.GetMonster(monsterEncounter.MonsterID);
                }
            }

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

 

Step 5: Modify \Engine\Models\Monster.cs

On line 3, add “using Engine.Services;”, so we can access the new DiceService class.

Change line 50 to use the new DiceService class to populate the monster’s loot.

 

Monster.cs

using System.Collections.Generic;
using Engine.Factories;
using Engine.Services;

namespace Engine.Models
{
    public class Monster : LivingEntity
    {
        private readonly List<ItemPercentage> _lootTable =
            new List<ItemPercentage>();

        public int ID { get; }
        public string ImageName { get; }
        public int RewardExperiencePoints { get; }

        public Monster(int id, string name, string imageName,
                       int maximumHitPoints, int dexterity,
                       GameItem currentWeapon,
                       int rewardExperiencePoints, int gold) :
            base(name, maximumHitPoints, maximumHitPoints, dexterity, gold)
        {
            ID = id;
            ImageName = imageName;
            CurrentWeapon = currentWeapon;
            RewardExperiencePoints = rewardExperiencePoints;
        }

        public void AddItemToLootTable(int id, int percentage)
        {
            // Remove the entry from the loot table,
            // if it already contains an entry with this ID
            _lootTable.RemoveAll(ip => ip.ID == id);

            _lootTable.Add(new ItemPercentage(id, percentage));
        }

        public Monster GetNewInstance()
        {
            // "Clone" this monster to a new Monster object
            Monster newMonster =
                new Monster(ID, Name, ImageName, MaximumHitPoints, Dexterity, 
                            CurrentWeapon, RewardExperiencePoints, Gold);

            foreach(ItemPercentage itemPercentage in _lootTable)
            {
                // Clone the loot table - even though we probably won't need it
                newMonster.AddItemToLootTable(itemPercentage.ID, itemPercentage.Percentage);

                // Populate the new monster's inventory, using the loot table
                if(DiceService.Instance.Roll(100).Value <= itemPercentage.Percentage)
                {
                    newMonster.AddItemToInventory(ItemFactory.CreateGameItem(itemPercentage.ID));
                }
            }

            return newMonster;
        }
    }
}

 

Step 6: Modify \Engine\ViewModels\GameSession.cs

Line 136 is where we use the random number generator to determine the player’s dexterity. Change it to use the new DiceService, where we “roll” a six-sided die three times.

 

GameSession.cs

using System.Linq;
using Engine.Factories;
using Engine.Models;
using Engine.Services;
using Newtonsoft.Json;

namespace Engine.ViewModels
{
    public class GameSession : BaseNotificationClass
    {
        private readonly MessageBroker _messageBroker = MessageBroker.GetInstance();

        #region Properties

        private Player _currentPlayer;
        private Location _currentLocation;
        private Battle _currentBattle;
        private Monster _currentMonster;
        private Trader _currentTrader;

        public string Version { get; } = "0.1.000";

        [JsonIgnore]
        public World CurrentWorld { get; }

        public Player CurrentPlayer
        {
            get => _currentPlayer;
            set
            {
                if(_currentPlayer != null)
                {
                    _currentPlayer.OnLeveledUp -= OnCurrentPlayerLeveledUp;
                    _currentPlayer.OnKilled -= OnPlayerKilled;
                }

                _currentPlayer = value;

                if(_currentPlayer != null)
                {
                    _currentPlayer.OnLeveledUp += OnCurrentPlayerLeveledUp;
                    _currentPlayer.OnKilled += OnPlayerKilled;
                }
            }
        }

        public Location CurrentLocation
        {
            get => _currentLocation;
            set
            {
                _currentLocation = value;

                OnPropertyChanged();
                OnPropertyChanged(nameof(HasLocationToNorth));
                OnPropertyChanged(nameof(HasLocationToEast));
                OnPropertyChanged(nameof(HasLocationToWest));
                OnPropertyChanged(nameof(HasLocationToSouth));

                CompleteQuestsAtLocation();
                GivePlayerQuestsAtLocation();
                CurrentMonster = CurrentLocation.GetMonster();

                CurrentTrader = CurrentLocation.TraderHere;
            }
        }

        [JsonIgnore]
        public Monster CurrentMonster
        {
            get => _currentMonster;
            set
            {
                if(_currentBattle != null)
                {
                    _currentBattle.OnCombatVictory -= OnCurrentMonsterKilled;
                    _currentBattle.Dispose();
                    _currentBattle = null;
                }

                _currentMonster = value;

                if(_currentMonster != null)
                {
                    _currentBattle = new Battle(CurrentPlayer, CurrentMonster);

                    _currentBattle.OnCombatVictory += OnCurrentMonsterKilled;
                }

                OnPropertyChanged();
                OnPropertyChanged(nameof(HasMonster));
            }
        }

        [JsonIgnore]
        public Trader CurrentTrader
        {
            get => _currentTrader;
            set
            {
                _currentTrader = value;

                OnPropertyChanged();
                OnPropertyChanged(nameof(HasTrader));
            }
        }

        [JsonIgnore]
        public bool HasLocationToNorth =>
            CurrentWorld.LocationAt(CurrentLocation.XCoordinate, CurrentLocation.YCoordinate + 1) != null;

        [JsonIgnore]
        public bool HasLocationToEast =>
            CurrentWorld.LocationAt(CurrentLocation.XCoordinate + 1, CurrentLocation.YCoordinate) != null;

        [JsonIgnore]
        public bool HasLocationToSouth =>
            CurrentWorld.LocationAt(CurrentLocation.XCoordinate, CurrentLocation.YCoordinate - 1) != null;

        [JsonIgnore]
        public bool HasLocationToWest =>
            CurrentWorld.LocationAt(CurrentLocation.XCoordinate - 1, CurrentLocation.YCoordinate) != null;

        [JsonIgnore]
        public bool HasMonster => CurrentMonster != null;

        [JsonIgnore]
        public bool HasTrader => CurrentTrader != null;

        #endregion

        public GameSession()
        {
            CurrentWorld = WorldFactory.CreateWorld();

            int dexterity = DiceService.Instance.Roll(6, 3).Value;

            CurrentPlayer = new Player("Scott", "Fighter", 0, 10, 10, dexterity, 1000000);

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

            CurrentPlayer.AddItemToInventory(ItemFactory.CreateGameItem(2001));
            CurrentPlayer.LearnRecipe(RecipeFactory.RecipeByID(1));
            CurrentPlayer.AddItemToInventory(ItemFactory.CreateGameItem(3001));
            CurrentPlayer.AddItemToInventory(ItemFactory.CreateGameItem(3002));
            CurrentPlayer.AddItemToInventory(ItemFactory.CreateGameItem(3003));

            CurrentLocation = CurrentWorld.LocationAt(0, 0);
        }

        public GameSession(Player player, int xCoordinate, int yCoordinate)
        {
            CurrentWorld = WorldFactory.CreateWorld();
            CurrentPlayer = player;
            CurrentLocation = CurrentWorld.LocationAt(xCoordinate, yCoordinate);
        }

        public void MoveNorth()
        {
            if(HasLocationToNorth)
            {
                CurrentLocation = CurrentWorld.LocationAt(CurrentLocation.XCoordinate, CurrentLocation.YCoordinate + 1);
            }
        }

        public void MoveEast()
        {
            if(HasLocationToEast)
            {
                CurrentLocation = CurrentWorld.LocationAt(CurrentLocation.XCoordinate + 1, CurrentLocation.YCoordinate);
            }
        }

        public void MoveSouth()
        {
            if(HasLocationToSouth)
            {
                CurrentLocation = CurrentWorld.LocationAt(CurrentLocation.XCoordinate, CurrentLocation.YCoordinate - 1);
            }
        }

        public void MoveWest()
        {
            if(HasLocationToWest)
            {
                CurrentLocation = CurrentWorld.LocationAt(CurrentLocation.XCoordinate - 1, CurrentLocation.YCoordinate);
            }
        }

        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))
                    {
                        CurrentPlayer.RemoveItemsFromInventory(quest.ItemsToComplete);

                        _messageBroker.RaiseMessage("");
                        _messageBroker.RaiseMessage($"You completed the '{quest.Name}' quest");

                        // Give the player the quest rewards
                        _messageBroker.RaiseMessage($"You receive {quest.RewardExperiencePoints} experience points");
                        CurrentPlayer.AddExperience(quest.RewardExperiencePoints);

                        _messageBroker.RaiseMessage($"You receive {quest.RewardGold} gold");
                        CurrentPlayer.ReceiveGold(quest.RewardGold);

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

                            _messageBroker.RaiseMessage($"You receive a {rewardItem.Name}");
                            CurrentPlayer.AddItemToInventory(rewardItem);
                        }

                        // Mark the Quest as completed
                        questToComplete.IsCompleted = true;
                    }
                }
            }
        }

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

                    _messageBroker.RaiseMessage("");
                    _messageBroker.RaiseMessage($"You receive the '{quest.Name}' quest");
                    _messageBroker.RaiseMessage(quest.Description);

                    _messageBroker.RaiseMessage("Return with:");
                    foreach(ItemQuantity itemQuantity in quest.ItemsToComplete)
                    {
                        _messageBroker
                            .RaiseMessage($"   {itemQuantity.Quantity} {ItemFactory.CreateGameItem(itemQuantity.ItemID).Name}");
                    }

                    _messageBroker.RaiseMessage("And you will receive:");
                    _messageBroker.RaiseMessage($"   {quest.RewardExperiencePoints} experience points");
                    _messageBroker.RaiseMessage($"   {quest.RewardGold} gold");
                    foreach(ItemQuantity itemQuantity in quest.RewardItems)
                    {
                        _messageBroker
                            .RaiseMessage($"   {itemQuantity.Quantity} {ItemFactory.CreateGameItem(itemQuantity.ItemID).Name}");
                    }
                }
            }
        }

        public void AttackCurrentMonster()
        {
            _currentBattle?.AttackOpponent();
        }

        public void UseCurrentConsumable()
        {
            if(CurrentPlayer.CurrentConsumable != null)
            {
                if (_currentBattle == null)
                {
                    CurrentPlayer.OnActionPerformed += OnConsumableActionPerformed;
                }

                CurrentPlayer.UseCurrentConsumable();

                if (_currentBattle == null)
                {
                    CurrentPlayer.OnActionPerformed -= OnConsumableActionPerformed;
                }
            }
        }

        private void OnConsumableActionPerformed(object sender, string result)
        {
            _messageBroker.RaiseMessage(result);
        }

        public void CraftItemUsing(Recipe recipe)
        {
            if(CurrentPlayer.Inventory.HasAllTheseItems(recipe.Ingredients))
            {
                CurrentPlayer.RemoveItemsFromInventory(recipe.Ingredients);

                foreach(ItemQuantity itemQuantity in recipe.OutputItems)
                {
                    for(int i = 0; i < itemQuantity.Quantity; i++)
                    {
                        GameItem outputItem = ItemFactory.CreateGameItem(itemQuantity.ItemID);
                        CurrentPlayer.AddItemToInventory(outputItem);
                        _messageBroker.RaiseMessage($"You craft 1 {outputItem.Name}");
                    }
                }
            }
            else
            {
                _messageBroker.RaiseMessage("You do not have the required ingredients:");
                foreach(ItemQuantity itemQuantity in recipe.Ingredients)
                {
                    _messageBroker
                        .RaiseMessage($"  {itemQuantity.Quantity} {ItemFactory.ItemName(itemQuantity.ItemID)}");
                }
            }
        }

        private void OnPlayerKilled(object sender, System.EventArgs e)
        {
            _messageBroker.RaiseMessage("");
            _messageBroker.RaiseMessage("You have been killed.");

            CurrentLocation = CurrentWorld.LocationAt(0, -1);
            CurrentPlayer.CompletelyHeal();
        }

        private void OnCurrentMonsterKilled(object sender, System.EventArgs eventArgs)
        {
            // Get another monster to fight
            CurrentMonster = CurrentLocation.GetMonster();
        }

        private void OnCurrentPlayerLeveledUp(object sender, System.EventArgs eventArgs)
        {
            _messageBroker.RaiseMessage($"You are now level {CurrentPlayer.Level}!");
        }
    }
}

 

Step 7: Modify \Engine\Actions\AttackWithWeapon.cs

We’re going to change our weapon data, in the game data files, to have a dice notation for the amount of damage the weapon does – instead of the current minimum and maximum damage values we have. So, we need to change the AttackWithWeapon class to use this new value when an attack happens.

Replace the private damage variables on lines 9 and 10 with a new private string variable to hold the dice notation “_damageDice”.

Change the AttackWithWeapon” function to accept a string parameter for the damage, instead of the existing two integer parameters.

Change lines 19 through 25 to validate the parameter. Because we’re using a dice notation string, we don’t need to check that the minimum damage is less than the maximum damage any longer.

Finally, inside the Execute function, on line 34, change the line where we calculate the damage to use the new DiceService class.

 

AttackWithWeapon.cs

using System;
using Engine.Models;
using Engine.Services;

namespace Engine.Actions
{
    public class AttackWithWeapon : BaseAction, IAction
    {
        private readonly string _damageDice;

        public AttackWithWeapon(GameItem itemInUse, string damageDice)
            : base(itemInUse)
        {
            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 void Execute(LivingEntity actor, LivingEntity target)
        {
            string actorName = (actor is Player) ? "You" : $"The {actor.Name.ToLower()}";
            string targetName = (target is Player) ? "you" : $"the {target.Name.ToLower()}";

            if(CombatService.AttackSucceeded(actor, target))
            {
                int damage = DiceService.Instance.Roll(_damageDice).Value;

                ReportResult($"{actorName} hit {targetName} for {damage} point{(damage > 1 ? "s" : "")}.");

                target.TakeDamage(damage);
            }
            else
            {
                ReportResult($"{actorName} missed {targetName}.");
            }
        }
    }
}

 

Step 8: Modify \TestEngine\Actions\TestAttackWithWeapon.cs

Now we need to change our unit tests for the AttackWithWeapon class.

Change lines 17 and 30 to pass in a dice notation of “1d5” when instantiating the AttackWithWeapon objects.

Then, change the test function that starts on line 35 to test that we get an error is we pass in an empty string to the AttackWithWeapon class constructor.

 

TestAttackWithWeapon.cs

using System;
using Engine.Actions;
using Engine.Factories;
using Engine.Models;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace TestEngine.Actions
{
    [TestClass]
    public class TestAttackWithWeapon
    {
        [TestMethod]
        public void Test_Constructor_GoodParameters()
        {
            GameItem pointyStick = ItemFactory.CreateGameItem(1001);

            AttackWithWeapon attackWithWeapon = new AttackWithWeapon(pointyStick, "1d5");

            Assert.IsNotNull(attackWithWeapon);
        }

        [TestMethod]
        [ExpectedException(typeof(ArgumentException))]
        public void Test_Constructor_ItemIsNotAWeapon()
        {
            GameItem granolaBar = ItemFactory.CreateGameItem(2001);

            // A granola bar is not a weapon.
            // So, the constructor should throw an exception.
            AttackWithWeapon attackWithWeapon = new AttackWithWeapon(granolaBar, "1d5");
        }

        [TestMethod]
        [ExpectedException(typeof(ArgumentException))]
        public void Test_Constructor_DamageDiceStringEmpty()
        {
            GameItem pointyStick = ItemFactory.CreateGameItem(1001);

            // This damage dice string must exist.
            // So, the constructor should throw an exception.
            AttackWithWeapon attackWithWeapon = new AttackWithWeapon(pointyStick, string.Empty);
        }
    }
}

 

Step 9: Modify \Engine\Factories\ItemFactory.cs

Change the AttackWithWeapon instantiation on lines 65-67 to get the weapon damage value from the attribute “DamageDice”, instead of the two previous attributes “MinimumDamage” and “MaximumDamage”.

 

ItemFactory.cs

using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Xml;
using Engine.Actions;
using Engine.Models;
using Engine.Shared;

namespace Engine.Factories
{
    public static class ItemFactory
    {
        private const string GAME_DATA_FILENAME = ".\\GameData\\GameItems.xml";

        private static readonly List<GameItem> _standardGameItems = new List<GameItem>();

        static ItemFactory()
        {
            if(File.Exists(GAME_DATA_FILENAME))
            {
                XmlDocument data = new XmlDocument();
                data.LoadXml(File.ReadAllText(GAME_DATA_FILENAME));

                LoadItemsFromNodes(data.SelectNodes("/GameItems/Weapons/Weapon"));
                LoadItemsFromNodes(data.SelectNodes("/GameItems/HealingItems/HealingItem"));
                LoadItemsFromNodes(data.SelectNodes("/GameItems/MiscellaneousItems/MiscellaneousItem"));
            }
            else
            {
                throw new FileNotFoundException($"Missing data file: {GAME_DATA_FILENAME}");
            }
        }

        public static GameItem CreateGameItem(int itemTypeID)
        {
            return _standardGameItems.FirstOrDefault(item => item.ItemTypeID == itemTypeID)?.Clone();
        }

        public static string ItemName(int itemTypeID)
        {
            return _standardGameItems.FirstOrDefault(i => i.ItemTypeID == itemTypeID)?.Name ?? "";
        }

        private static void LoadItemsFromNodes(XmlNodeList nodes)
        {
            if(nodes == null)
            {
                return;
            }

            foreach(XmlNode node in nodes)
            {
                GameItem.ItemCategory itemCategory = DetermineItemCategory(node.Name);
                
                GameItem gameItem =
                    new GameItem(itemCategory,
                                 node.AttributeAsInt("ID"),
                                 node.AttributeAsString("Name"),
                                 node.AttributeAsInt("Price"),
                                 itemCategory == GameItem.ItemCategory.Weapon);

                if(itemCategory == GameItem.ItemCategory.Weapon)
                {
                    gameItem.Action =
                        new AttackWithWeapon(gameItem, node.AttributeAsString("DamageDice"));
                }
                else if(itemCategory == GameItem.ItemCategory.Consumable)
                {
                    gameItem.Action =
                        new Heal(gameItem,
                                 node.AttributeAsInt("HitPointsToHeal"));
                }

                _standardGameItems.Add(gameItem);
            }
        }

        private static GameItem.ItemCategory DetermineItemCategory(string itemType)
        {
            switch(itemType)
            {
                case "Weapon":
                    return GameItem.ItemCategory.Weapon;
                case "HealingItem":
                    return GameItem.ItemCategory.Consumable;
                default:
                    return GameItem.ItemCategory.Miscellaneous;
            }
        }
    }
}

 

Step 10: Modify \Engine\GameData\GameItems.xml

Change lines 4 through 8, replacing the “MinimumDamage” and “MaximumDamage” attributes with the “DamageDice” attribute.

 

GameItems.xml

<?xml version="1.0" encoding="utf-8" ?>
<GameItems>
  <Weapons>
    <Weapon ID="1001" Name="Pointy stick" Price="1" DamageDice="1d2"/>
    <Weapon ID="1002" Name="Rusty sword" Price="5" DamageDice="1d3"/>
    <Weapon ID="1501" Name="Snake fang" Price="0" DamageDice="1d2"/>
    <Weapon ID="1502" Name="Rat claw" Price="0" DamageDice="1d2"/>
    <Weapon ID="1503" Name="Spider fang" Price="0" DamageDice="1d4"/>
  </Weapons>
  <HealingItems>
    <HealingItem ID="2001" Name="Granola bar" Price="5" HitPointsToHeal="2"/>
  </HealingItems>
  <MiscellaneousItems>
    <MiscellaneousItem ID="3001" Name="Oats" Price="1"/>
    <MiscellaneousItem ID="3002" Name="Honey" Price="2"/>
    <MiscellaneousItem ID="3003" Name="Raisins" Price="2"/>
    <MiscellaneousItem ID="9001" Name="Snake fang" Price="1"/>
    <MiscellaneousItem ID="9002" Name="Snakeskin" Price="2"/>
    <MiscellaneousItem ID="9003" Name="Rat tail" Price="1"/>
    <MiscellaneousItem ID="9004" Name="Rat fur" Price="2"/>
    <MiscellaneousItem ID="9005" Name="Spider fang" Price="1"/>
    <MiscellaneousItem ID="9006" Name="Spider silk" Price="2"/>
  </MiscellaneousItems>
</GameItems>

 

Step 11: Modify \Engine\Services\CombatService.cs

Replace the previous calls to RandomNumberGenerator.NumberBetween() to DiceService.Instance.Roll() on lines 20, 23, 36, and 39.

 

CombatService.cs

using Engine.Models;

namespace Engine.Services
{
    public static class CombatService
    {
        public enum Combatant
        {
            Player,
            Opponent
        }

        public static Combatant FirstAttacker(Player player, Monster opponent)
        {
            // Formula is: ((Dex(player)^2 - Dex(monster)^2)/10) + Random(-10/10)
            // For dexterity values from 3 to 18, this should produce an offset of +/- 41.5
            int playerDexterity = player.Dexterity * player.Dexterity;
            int opponentDexterity = opponent.Dexterity * opponent.Dexterity;
            decimal dexterityOffset = (playerDexterity - opponentDexterity) / 10m;
            int randomOffset = DiceService.Instance.Roll(20).Value - 10;
            decimal totalOffset = dexterityOffset + randomOffset;

            return DiceService.Instance.Roll(100).Value <= 50 + totalOffset 
                       ? Combatant.Player 
                       : Combatant.Opponent;
        }

        public static bool AttackSucceeded(LivingEntity attacker, LivingEntity target)
        {
            // Currently using the same formula as FirstAttacker initiative.
            // This will change as we include attack/defense skills,
            // armor, weapon bonuses, enchantments/curses, etc.
            int playerDexterity = attacker.Dexterity * attacker.Dexterity;
            int opponentDexterity = target.Dexterity * target.Dexterity;
            decimal dexterityOffset = (playerDexterity - opponentDexterity) / 10m;
            int randomOffset = DiceService.Instance.Roll(20).Value - 10;
            decimal totalOffset = dexterityOffset + randomOffset;

            return DiceService.Instance.Roll(100).Value <= 50 + totalOffset;
        }
    }
}

 

Step 12: Add licensing information

I’m not a lawyer, so this isn’t legal advice.

When you use an open source library in a program, there is often a requirement to include a copy of their license with your program. We need to do this for some of NuGet packages we’re using in the game.

So, right-click on the solution name in Solution Explorer and select “New Solution Folder” and name it, “Docs”.

Right-click on the new “Docs” folder and select “Add” -> “New Item…”. Select “Text File”, name it “LicensesConsumed.md”, and paste in the text below. This is where we’ll add any license information for libraries we include in the game.

We probably don’t need to do this for a personal project. But, I think it’s good to recognize people who contribute to open source projects.

 

LicensesConsumed.md

#Included Package Licenses:

As part of the SOSCSRPG project, we use a few NuGet packages from external developers. As part of their license agreements, we will list those components here with their license.

##D20Tek.DiceNotation.Net47 & D20Tek.Common.Net47:
--------------------------------------------------
MIT License

Copyright (c) 2020 DarthPedro

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

https://dev.azure.com/d20Tek/Libraries/_git/dicenotation?path=%2FLICENSE

##Newtonsoft.Json:
------------------
The MIT License (MIT)

Copyright (c) 2007 James Newton-King

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

https://github.com/JamesNK/Newtonsoft.Json/blob/master/LICENSE.md

##MathNet.Numerics:
-------------------
Copyright (c) 2002-2020 Math.NET

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

https://github.com/mathnet/mathnet-numerics/blob/master/LICENSE.md

 

Step 13: Test the game

After making all these changes, run the unit tests, and run the game – to make sure everything still works.

 

 

Additional links for this project

Source code: https://github.com/ScottLilly/SOSCSRPG

Project plan: https://github.com/ScottLilly/SOSCSRPG/projects/1

Discord: https://discord.gg/AUYXYtH

Return to main page

Leave a Reply

Your email address will not be published. Required fields are marked *