(#100DaysOfCode) Day Four – Getting Ready To Play the Game

Yesterday I said we would be back in the web app but it looks like that isn’t going to happen now until day five. What I didn’t realize was how much additional code I needed to write at the engine level to support storing of game data and rendering the now complete (if brief) biographies in the character library resource.

So firstly, in order to make the biographies dynamic I decided to use replacement tokens to allow for text to be dynamically changed if, for example, the player selects a different gender or provides a different name.

Here is a sample bio for the second character:-

[p][Prop.FormalTitle] [Prop.LastName] is the only child of Hotaru Ito and Isamu Tanaka. [HeShe] followed in the family tradition and persued a military career in the United Earth armed forces before joining the UPF navy. [HisHer] first command was as captain of the UPFS Kyuseishu one of the first vessels to sucessfully evacuate Mars during the invasion of the Harvesters[/p][p][Prop.FormalTitle] [Prop.LastName] is a skilled tacticion with impressive marksmanship and unarmed combat skills.[/p]
    

Aside from HTML tags like [p] and [/p] there are some gender-specific tags likes [HisHer] and [HeShe] There are also tags starting with [Prop.blah] which are where the text can be replaced with property values of the character object. I won’t go into much detail here but in order to do this text processing, I added an extensions class to the game engine project that did some string.Replace work to replace these tokens:-

Extensions.TextProcessing.cs:-

using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
using Cybermancer.CelestialAberration.GameEngine.Enumerations;

namespace Cybermancer.CelestialAberration.GameEngine.Extensions
{
    public static class TextProcessing
    {
        public static string ToHtml(this string data)
        {
            var modifiedData = data.Replace("[br]", "<br/>");
            modifiedData = modifiedData.Replace("[p]", "<p>");
            modifiedData = modifiedData.Replace("[/p]", "</p>");
            return modifiedData;
        }

        public static string Genderise(this string data, Gender gender)
        {
            var modifiedData = data;

            switch (gender)
            {
                case Gender.Female:
                    modifiedData = modifiedData.Replace("[HeShe]", "She");
                    modifiedData = modifiedData.Replace("[heshe]", "she");
                    modifiedData = modifiedData.Replace("[himher]", "her");
                    modifiedData = modifiedData.Replace("[HisHer]", "Her");
                    modifiedData = modifiedData.Replace("[hisher]", "her");
                    break;
                case Gender.Male:
                    modifiedData = modifiedData.Replace("[HeShe]", "He");
                    modifiedData = modifiedData.Replace("[heshe]", "he");
                    modifiedData = modifiedData.Replace("[himher]", "him");
                    modifiedData = modifiedData.Replace("[HisHer]", "His");
                    modifiedData = modifiedData.Replace("[hisher]", "his");
                    break;
                case Gender.Neutral:
                    modifiedData = modifiedData.Replace("[HeShe]", "It");
                    modifiedData = modifiedData.Replace("[heshe]", "it");
                    modifiedData = modifiedData.Replace("[himher]", "it");
                    modifiedData = modifiedData.Replace("[HisHer]", "Its");
                    modifiedData = modifiedData.Replace("[hisher]", "its");
                    break;
            }

            return modifiedData;
        }

        public static string Posessive(this string data)
        {
            var posessiveRegxPattern = ".\\['s\\]";

            var regex = new Regex(posessiveRegxPattern, RegexOptions.CultureInvariant);
            var matches = regex.Matches(data);
            var modifiedData = data;

            foreach (Match match in matches)
            {
                var matchValue = match.Groups[0].Value[0].ToString();

                if (matchValue == "s")
                {
                    modifiedData = modifiedData.Replace($"{matchValue}['s]", $"{matchValue}'");
                }
                else
                {
                    modifiedData = modifiedData.Replace($"{matchValue}['s]", $"{matchValue}'s");
                }
            }

            return modifiedData;
        }

        public static string InsertProperties<T>(this string data, T objectData)
        {
            var modifiedData = data.InsertStringListProperties(objectData);

            var propertyRegxPattern = "\\[Prop\\.[^\\s]+\\]";
            var regex = new Regex(propertyRegxPattern, RegexOptions.CultureInvariant);
            var matches = regex.Matches(modifiedData);

            foreach (Match match in matches)
            {
                modifiedData = modifiedData.InsertProperty(match.Groups[0].Value, objectData);
            }

            return modifiedData;
        }

        public static string InsertProperty<T>(this string data, string propertyToken, T objectData)
        {
            var modifiedData = data;
            var propertyToInsert = propertyToken.Replace("[Prop.", "").Replace("]", "");

            var value = typeof(T).GetProperty(propertyToInsert).GetValue(objectData);

            return modifiedData.Replace(propertyToken, value.ToString());
        }

        public static string InsertStringListItem<T>(this string data, string propertyToken, T objectData)
        {
            var modifiedData = data;
            var startIndexerPos = propertyToken.Substring(1).IndexOf("[");
            var endIndexerPos = propertyToken.IndexOf("]");

            var listPropertyToInsert = propertyToken.Substring(1, startIndexerPos).Replace("Prop.", "");
            var itemIndex = int.Parse(propertyToken.Substring(startIndexerPos + 2, endIndexerPos - startIndexerPos - 2));

            var listItem = (List<string>)typeof(T).GetProperty(listPropertyToInsert).GetValue(objectData);

            return modifiedData.Replace(propertyToken, listItem[itemIndex]);
        }

        public static string ParseText(this string data)
        {
            return data.Posessive().ToHtml();
        }

        public static string ParseTextFor<T>(this string data, T objectData)
        {
            return data.InsertProperties(objectData).ParseText();
        }

        private static string InsertStringListProperties<T>(this string data, T objectData)
        {
 
            var propertyRegxPattern = "\\[Prop\\.[^\\s]+\\]\\]";
            var regex = new Regex(propertyRegxPattern, RegexOptions.CultureInvariant);
            var matches = regex.Matches(data);

            var modifiedData = data;

            foreach (Match match in matches)
            {
                modifiedData = modifiedData.InsertStringListItem(match.Groups[0].Value, objectData);
            }

            return modifiedData;
        }
    }
}

The above file makes use of generics (a very useful feature of C#) and regular expressions. Whilst I’m on the subject of regular expressions here is a link to a very handy .NET friendly regular expression testing tool.

In the character class I added a new read only property to get the BiographyText after it had been passed through the extension methods above:-

using Cybermancer.CelestialAberration.GameEngine.Enumerations;
using System.Collections.Generic;
using Cybermancer.CelestialAberration.GameEngine.Extensions;

namespace Cybermancer.CelestialAberration.GameEngine.GameObjects
{
    public class Character
    {
        public string CharacterId { get; set; }

        public List<string> FirstNames { get; set; }

        public string LastName { get; set; }

        public string FamiliarName { get; set; }

        public string FormalTitle { get; set; }

        public Gender Gender { get; set; }

        public SexualOrientation SexualOrientation { get; set; }

        public string Nationality { get; set; }

        public Position Position { get; set; }

        public string Biography { get; set; }

        public string BiographyText => Biography.ParseTextFor(this).Genderise(Gender); 
    }
}

Now on to allowing read and write operations to the transient game data. Within a game data repository we could just have methods to operate on the collection of stored games, but there may be times when we just want to see what games are in the repository without retrieving all the game data (which could be quite big). So I came up with the concept of a game index, which is like a catalog pointing at all the stored saved games. The starting point for this is a GameDataInfo object that holds information about a particular game.

using System;

namespace Cybermancer.CelestialAberration.GameEngine.GameObjects
{
    public class GameDataInfo : SerializableGameObject
    {
        public string Id { get; set; } 
        public string Language { get; set; }
        public DateTime LastModified { get; set; }
    }
}

The GameIndex class itself is a list of these game info objects, alongside methods to insert, update and remove them.

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

namespace Cybermancer.CelestialAberration.GameEngine.GameObjects
{
    public class GameIndex : SerializableGameObject
    {
        public List<GameDataInfo> GameCatalog { get; set; }

        public GameIndex()
        {
            GameCatalog = new List<GameDataInfo>();
        }

        public GameDataInfo GetGameInfo(string id) => GameCatalog.FirstOrDefault(info => info.Id == id);

        public void RemoveGameInfo(string id)
        {
            var gameInfo = GetGameInfo(id);

            if (gameInfo != null)
            {
                GameCatalog.Remove(gameInfo);
            }
        }

        public void StoreGameInfo(GameDataInfo info)
        {
            var infoInStore = GetGameInfo(info.Id);

            if (infoInStore != null)
            {
                infoInStore.Language = info.Language;
                infoInStore.LastModified = info.LastModified;
            }
            else
            {
                GameCatalog.Add(info);
            }
        }
    }
}

Now onto the game repository. Still in the game engine I created an IGameDataRepository interface to provide methods for manipulating the saved game data.

using Cybermancer.CelestialAberration.GameEngine.GameObjects;

namespace Cybermancer.CelestialAberration.GameEngine.Repositories
{
    public interface IGameDataRepository
    {
        GameIndex GetGameIndex();
        GameData GetGameData(string Id);
        void StoreGameData(GameData data);
        void DeleteGameData(GameData data);
    }
}

The implementation of this class lives in the MVC application in its own repositories directory:-

using System;
using System.IO;
using Cybermancer.CelestialAberration.GameEngine.GameObjects;
using Cybermancer.CelestialAberration.GameEngine.Repositories;
using Microsoft.AspNetCore.Hosting;
using Newtonsoft.Json;

namespace CyberMancer.CelestialAberration.WebApplication.Repositories
{
    public class GameDataRepository : IGameDataRepository
    {
        private string _gameDataDirectory;

        private string _indexFilePath;

        public GameDataRepository(IHostingEnvironment hostingEnvironment)
        {
            _gameDataDirectory = Path.Combine(hostingEnvironment.ContentRootPath, "Games");
            _indexFilePath = Path.Combine(_gameDataDirectory, "index.json");
        }
   
        public void DeleteGameData(GameData data)
        {
            var filePath = Path.Combine(_gameDataDirectory, $"{data.Id}.json");
            if (File.Exists(filePath))
            {
                File.Delete(filePath);
                RemoveGameFromCatalog(data.Id);
            }
            else
            {
                throw new ArgumentException($"Game data for id {data.Id} not found.");
            }
        }

        public GameData GetGameData(string id)
        {
            var gameIndex = LoadGameIndex();

            var gameInfo = gameIndex.GetGameInfo(id);

            if (gameInfo == null)
            {
                throw new ArgumentException($"Game data for id {id} not found.");
            }

            return LoadGameData(gameInfo.Id);
        }

        public GameIndex GetGameIndex()
        {
            return LoadGameIndex();
        }

        public void StoreGameData(GameData data)
        {
            SaveGameData(data);

            var gameIndex = LoadGameIndex();

            gameIndex.StoreGameInfo(new GameDataInfo()
            {
                Id = data.Id,
                Language = data.Language,
                LastModified = data.LastModified
            });

            SaveGameIndex(gameIndex);
        }

        private GameIndex LoadGameIndex()
        {
            if (File.Exists(_indexFilePath))
            {
                return JsonConvert.DeserializeObject<GameIndex>(File.ReadAllText(_indexFilePath));
            }
            else
            {
                return new GameIndex();
            }
        }

        private void RemoveGameFromCatalog(string id)
        {
            var gameIndex = LoadGameIndex();

            gameIndex.RemoveGameInfo(id);

            SaveGameIndex(gameIndex);
        }

        private void SaveGameIndex(GameIndex gameIndex) => File.WriteAllText(_indexFilePath, gameIndex.ToString());

        private GameData LoadGameData(string id)
        {
            var filePath = Path.Combine(_gameDataDirectory, $"{id}.json");
            if (File.Exists(filePath))
            {
                var gameDataAsString = File.ReadAllText(filePath);
                return JsonConvert.DeserializeObject<GameData>(gameDataAsString);
            }
            else
            {
                throw new ArgumentException($"Game data for id {id} not found.");
            }
        }

        private void SaveGameData(GameData data)
        {
            var filePath = Path.Combine(_gameDataDirectory, $"{data.Id}.json");

            File.WriteAllText(filePath, data.ToString());
        }
    }
}

This code makes use of the .NET core hosting environment which is passed to its constructor. Provided our controller is also passed an
IHostingEnvironment object then this will be automatically injected into the controller whenever it is called. We use hostingEnvironment.ContentRootPath because we are storing app data that we don’t just want the user to point their browser directly at.

I forgot to mention that I have added some unit tests for the text processor and for the BiographyText property of the Character class

using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Collections.Generic;
using Cybermancer.CelestialAberration.GameEngine.Enumerations;
using Cybermancer.CelestialAberration.GameEngine.Extensions;
using Cybermancer.CelestialAberration.GameEngine.GameObjects;

namespace Cybermancer.CelestialAberration.GameEngineTests.ExtensionsTests
{
    [TestClass]
    public class TextProcessingTests
    {
        [TestMethod]
        public void EnsureTohtmlMethodWorksAsExpected()
        {
            var testData = "[p]paragraph[br]with line break[/p]";
            var expectedData = "<p>paragraph<br/>with line break</p>";

            Assert.AreEqual(expectedData, testData.ToHtml());
        }

        [TestMethod]
        public void EnsureGenderiseMethodWorksAsExpected()
        {
            var testData = "[HeShe] has [hisher] wallet with [himher]. [HisHer] wallet is here.";
            var femaleVersion = "She has her wallet with her. Her wallet is here.";
            var maleVersion = "He has his wallet with him. His wallet is here.";
            var neutralVersion = "It has its wallet with it. Its wallet is here.";

            Assert.AreEqual(femaleVersion, testData.Genderise(Gender.Female));
            Assert.AreEqual(maleVersion, testData.Genderise(Gender.Male));
            Assert.AreEqual(neutralVersion, testData.Genderise(Gender.Neutral));
        }

        [TestMethod]
        public void EnsurePosessiveMethodWorksAsExpected()
        {
            var testData = "John['s] The Slaters['s]";
            var expectedData = "John's The Slaters'";

            Assert.AreEqual(expectedData, testData.Posessive());
        }

        [TestMethod]
        public void EnsureInsertPropertyMethodWorksAsExpected()
        {
            var character = new Character()
            {
                LastName = "Stone",
            };

            var testData = "[Prop.LastName]";
            var expectedData = "Stone";

            Assert.AreEqual(expectedData, testData.InsertProperty("[Prop.LastName]", character));
        }

        [TestMethod]
        public void EnsureInsertStringListItemMethodWorksAsExpected()
        {
            var character = new Character()
            {
                FirstNames = new List<string>() {"Tom", "Dick"}
            };

            var testData = "[Prop.FirstNames[0]]";
            var expectedData = "Tom";

            Assert.AreEqual(expectedData, testData.InsertStringListItem("[Prop.FirstNames[0]]", character));
        }

        [TestMethod]
        public void EnsureInsertPropertiesMethodWorksAsExpected()
        {
            var character = new Character()
            {
                FirstNames = new List<string>() { "Richard", "Alexander" },
                LastName = "Stone",
                FamiliarName = "Richard",
                FormalTitle = "Mr",
                Gender = Gender.Male,
                SexualOrientation = SexualOrientation.Heterosexual,
                Position = Position.Captain
            };

            var testData = "[Prop.FormalTitle] [Prop.FirstNames[0]] [Prop.FirstNames[1]] [Prop.LastName]";
            var expectedData = "Mr Richard Alexander Stone";

            Assert.AreEqual(expectedData, testData.InsertProperties(character));
        }
    }
}
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Cybermancer.CelestialAberration.GameEngine.GameObjects;
using Cybermancer.CelestialAberration.GameEngine.Enumerations;
using System.Collections.Generic;

namespace Cybermancer.CelestialAberration.GameEngineTests.GameObjectTests
{
    [TestClass]
    public class CharacterTests
    {
        [TestMethod]
        public void EnsureBiographyTextPropertyReturnsCorrectStringValue()
        {
            var expectedOutput =
                "<p>Born on Mars, Richard was the first child of English parents Susannah Evans, an exobiologist and Alexander Stone, a software engineer. He is the eldest of three children, having a younger brother (Mark) and a younger sister (Juliette). Richard shared his father's aptitude for technology and studied advanced robotics and artificial intelligence before joining the Martian Navy as a scout pilot. He slowly made his way up the ranks to become first officer aboard the UPFS Kyuseishu under the command of Nozomi Tanaka.</p><p>Richard helped oversee the evacuation of Mars when the Harvesters invaded. The Voidwalker will be Richard's first command. Richard's strenghs lie in his high degree of techincal aptitude and scientific knowledge.</p>";

            var character = new Character()
            {
                FirstNames = new List<string>() { "Richard", "Alexander" },
                LastName = "Stone",
                FamiliarName = "Richard",
                Gender = Gender.Male,
                Nationality = "English",
                Biography = "[p]Born on Mars, [Prop.FirstNames[0]] was the first child of [Prop.Nationality] parents Susannah Evans, an exobiologist and Alexander [Prop.LastName], a software engineer. [HeShe] is the eldest of three children, having a younger brother (Mark) and a younger sister (Juliette). [Prop.FirstNames[0]] shared his father's aptitude for technology and studied advanced robotics and artificial intelligence before joining the Martian Navy as a scout pilot. [HeShe] slowly made his way up the ranks to become first officer aboard the UPFS Kyuseishu under the command of Nozomi Tanaka.[/p][p][Prop.FirstNames[0]] helped oversee the evacuation of Mars when the Harvesters invaded. The Voidwalker will be [Prop.FirstNames[0]]['s] first command. [Prop.FirstNames[0]]['s] strenghs lie in [hisher] high degree of techincal aptitude and scientific knowledge.[/p]"
            };

            Assert.AreEqual(expectedOutput, character.BiographyText);
        }
    }
}

And here is a screenshot of all our unit tests passing:-

That’s it for today. Hopefully we will now be able to concentrate on the web app and create a new game and player character tomorrow.

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

This site uses Akismet to reduce spam. Learn how your comment data is processed.