Lesson 5.14: Use Service Client to Retrieve ItemTemplates From Game Services

In this lesson, we will refactor the ItemFactory to use the GameServiceClient to load item data from our game services rather than from the local resource file. We will fetch the ItemTemplates during the application startup so that any latency is part of the launch. We already have loading logic and a progress indicator at that time, so it makes sense to load the game data there as well.

The ItemTemplate data is then cached in memory by the ItemFactory and lives for the lifetime of the app. When the game restarts, the data is fetched again. This keeps our remaining game code the same and we minimize any latency issues during gameplay.

Because we are fetching data from a web service, we will refactor our loading logic to become asynchronous. We don’t want to block our UI thread during these operations, so the async methods allow us to update the UI and progress indicator while we make the web service calls.

Refactor ItemFactory

To enable asynchronous fetching of ItemTemplates, we removed the Load call from the ItemFactory constructor. Constructors cannot be made async, so we need the Load method called from another component.

Let’s update the ItemFactory class with the following code:

using SimpleRPG.Game.Engine.Actions;
using SimpleRPG.Game.Engine.Factories.DTO;
using SimpleRPG.Game.Engine.Models;
using SimpleRPG.Game.Engine.Services;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;

namespace SimpleRPG.Game.Engine.Factories
{
    internal static class ItemFactory
    {
        private const string _serviceUrl = "https://simple-rpg-services.azurewebsites.net/api/item";
        private static readonly List<GameItem> _standardGameItems = new List<GameItem>();

        public static GameItem CreateGameItem(int itemTypeID)
        {
            var standardItem = _standardGameItems.First(i => i.ItemTypeID == itemTypeID);

            return standardItem.Clone();
        }

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

        public static async Task Load(HttpClient httpClient)
        {
            if (_standardGameItems.Any())
            {
                return;
            }

            var client = new GameServiceClient<ItemTemplate, int>(httpClient, _serviceUrl);
            var templates = await client.GetAllEntities().ConfigureAwait(false);
            foreach (var tmp in templates)
            {
                switch (tmp.Category)
                {
                    case GameItem.ItemCategory.Weapon:
                        BuildWeapon(tmp.Id, tmp.Name, tmp.Price, tmp.Damage);
                        break;
                    case GameItem.ItemCategory.Consumable:
                        BuildHealingItem(tmp.Id, tmp.Name, tmp.Price, tmp.Heals);
                        break;
                    default:
                        BuildMiscellaneousItem(tmp.Id, tmp.Name, tmp.Price);
                        break;
                }
            }
        }

        private static void BuildMiscellaneousItem(int id, string name, int price) =>
            _standardGameItems.Add(new GameItem(id, GameItem.ItemCategory.Miscellaneous, name, price));

        private static void BuildWeapon(int id, string name, int price, string damageDice)
        {
            var weapon = new GameItem(id, GameItem.ItemCategory.Weapon, name, price, true);
            weapon.SetAction(new Attack(weapon, damageDice));
            _standardGameItems.Add(weapon);
        }

        private static void BuildHealingItem(int id, string name, int price, int healPoints)
        {
            GameItem item = new GameItem(id, GameItem.ItemCategory.Consumable, name, price);
            item.SetAction(new Heal(item, healPoints));
            _standardGameItems.Add(item);
        }
    }
}
  1. We removed the ItemFactory constructor.
  2. We removed the _resourceNamespace member variable since we won’t need that any longer.
  3. We also deleted the items.json file from the Data folder. We did this to ensure we weren’t inadvertently still loading data locally.
  4. We added the _serviceUrl member variable (line #14) that holds the full url string to the item resource endpoint in our game services.
  5. We changed the definition of the Load method to be public now, async, and return a Task (line #29). We also pass in the HttpClient to use for our web service calls. The HttpClient is defined by the caller and will use the system cached service in our Blazor game.
  6. We check if there are any items already loaded. If there are, then we have already previously loaded items and can skip this operation.
  7. Then, we create an instance of GameServiceClient<ItemTemplate, int> to communicate with the service endpoint for ItemTemplates. We initialize the client with the specified HttpClient and the service url for this factory.
  8. Finally, we call the GameServiceClient.GetAllEntities method to retrieve all of the ItemTemplates from the game service endpoint. By using the GameServiceClient, our factory code doesn’t need to be aware of how to communicate with web services, retrieve json data, and deserialize it into objects. That is all encapsulated in the client class.

The remainder of the ItemFactory code remains exactly the same. And our game logic code doesn’t need to change how it calls the ItemFactory throughout our library.

Create FactoryLoader Class

Since our factory classes are internal (and we don’t want to expose that implementation outside of our library) and we need a central place to load data for all factories, we are going to define a public helper class, FactoryLoader, that will manage that for us.

Create the FactoryLoader class in the SimpleRPG.Game.Engine project and Factories folder.

using System.Net.Http;
using System.Threading.Tasks;

namespace SimpleRPG.Game.Engine.Factories
{
    public static class FactoryLoader
    {
        public static async Task Load(HttpClient httpClient)
        {
            await ItemFactory.Load(httpClient).ConfigureAwait(false);
        }
    }
}
  1. This is a static class that can be called from anywhere. We will use it both in our unit tests and in the Blazor game project.
  2. We define a public static Load method. This method is also async so that we can call our factory Load methods asynchronously as well.
  3. This method also takes an HttpClient as a parameter to pass to individual factory Load methods.
  4. Finally, it calls the ItemFactory.Load method. In this lesson, we only load the ItemFactory because that is the only factory we changed thus far. But in the next lesson, we will load all 6 of our factory classes here.

This code encapsulates all of our factory loading and provides a public method to perform this operation for all of our upstream callers – Blazor game app and unit tests.

Update Tests to Handle Async Loading

By changing how we Load the factory data, we have broken all of our ItemFactory tests and other tests that were dependent on ItemFactory. We need to take the opportunity to fix all of our tests now. We are not going to go through all of the test updates in this lesson, but we will focus on getting the ItemFactory tests fixed. The other test fixes are included in the commit for this lesson.

First let’s create the EngineTestFixture class in the root folder of the SimpleRPG.Game.Engine.Test project.

using SimpleRPG.Game.Engine.Factories;
using System.Net.Http;
using System.Threading.Tasks;

namespace SimpleRPG.Game.Engine.Tests
{
    internal static class EngineTestFixture
    {
        public static async Task InitializeServices()
        {
            var httpClient = new HttpClient();

            // initialize factory services
            await FactoryLoader.Load(httpClient).ConfigureAwait(false);
        }
    }
}

This test fixture is used to setup the services needed for our test context. For its implementation, we just create an HttpClient and call the FactoryLoader.Load method. This ensures that our factories are configured correctly before any tests are run.

Then we update the ItemFactoryTests class to use the EngineTextFixture to setup each test.

using SimpleRPG.Game.Engine.Factories;
using System;
using System.Threading.Tasks;
using Xunit;

namespace SimpleRPG.Game.Engine.Tests.Factories
{
    public class ItemFactoryTests
    {
        [Fact]
        public async Task CreateGameItem_WithValidItemTypeId()
        {
            // arrange
            await EngineTestFixture.InitializeServices().ConfigureAwait(false);

            // act
            var item = ItemFactory.CreateGameItem(1001);

            // assert
            Assert.NotNull(item);
            Assert.Equal(1001, item.ItemTypeID);
            Assert.Equal("Pointy stick", item.Name);
            Assert.Equal(1, item.Price);
        }

        [Fact]
        public async Task CreateGameItem_WithInvalidItemTypeId()
        {
            // arrange
            await EngineTestFixture.InitializeServices().ConfigureAwait(false);

            // act - asset
            Assert.Throws<InvalidOperationException>(() => ItemFactory.CreateGameItem(1));
        }

        [Fact]
        public async Task GetItemName()
        {
            // arrange
            await EngineTestFixture.InitializeServices().ConfigureAwait(false);

            // act
            var name = ItemFactory.GetItemName(3001);

            // assert
            Assert.Equal("Oats", name);
        }

        [Fact]
        public async Task GetItemName_WithInvalidId()
        {
            // arrange
            await EngineTestFixture.InitializeServices().ConfigureAwait(false);

            // act
            var name = ItemFactory.GetItemName(42);

            // assert
            Assert.Empty(name);
        }
    }
}

For each test, we changed the test method signature to be async and return a Task… to make the tests asynchronous. And we call the EngineTestFixture.InitializeServices method in the setup of each test method. We followed this same pattern to fix the other tests that need ItemFactory data loaded. But test test execution and validation code remained as it was.

After all of the test changes, we can build our game project again and run all of the tests successfully.

Update Game Launch to Load Data

With our engine code and test back to green, we are ready to update the SimpleRPG.Game project to load data from our game services. To enable this, we will update the Program.cs file.

using Blazorise;
using Blazorise.Bootstrap;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.DependencyInjection;
using SimpleRPG.Game.Engine.Factories;
using SimpleRPG.Game.Engine.Services;
using SimpleRPG.Game.Engine.ViewModels;
using System;
using System.Diagnostics.CodeAnalysis;
using System.Net.Http;
using System.Threading.Tasks;

namespace SimpleRPG.Game
{
    /// <summary>
    /// Main program class.
    /// </summary>
    [ExcludeFromCodeCoverage]
    public sealed class Program
    {
        public static async Task Main(string[] args)
        {
            var builder = WebAssemblyHostBuilder.CreateDefault(args);

            builder.Services
              .AddBlazorise(options =>
              {
                  options.ChangeTextOnKeyPress = false;
              })
              .AddBootstrapProviders();

            builder.RootComponents.Add<App>("app");
            builder.Services.AddTransient(sp => new HttpClient
            {
                BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
            });

            // add app-specific/custom services and view models here...
            ConfigureAppServices(builder.Services);

            var host = builder.Build();
            host.Services
              .UseBootstrapProviders();

            // initialize app-specific/custom services and view models here...
            await InitializeAppServices(host.Services).ConfigureAwait(false);

            await host.RunAsync().ConfigureAwait(false);
        }

        private static void ConfigureAppServices(IServiceCollection services)
        {
            // add app-specific/custom services here...
            services.AddSingleton<IDiceService>(DiceService.Instance);
            services.AddSingleton<IGameSession, GameSession>();
            services.AddTransient<TraderViewModel>();
        }

        private static async Task InitializeAppServices(IServiceProvider serviceProvider)
        {
            // add service initialization here...
            var httpClient = serviceProvider.GetRequiredService<HttpClient>();
            await FactoryLoader.Load(httpClient).ConfigureAwait(false);
        }
    }
}

We updated the InitializeAppServices method (lines #59-63) in the file to follow the async-await pattern by labeling it with the async keyword and returning a Task. Then, we get the application’s HttpClient that was registered with the dependency injection engine. Finally, we use the FactoryLoader to load the data into the ItemFactory… and will load other factory data in the future.

Finally, we update line #46 to add an await keyword since the method call is now also asynchronous.

These are all of the code changes required to add this support to our game project. If we now build and try to run our game locally, we will get an exception thrown during the app initialization. Our client call to the game services will thrown an exception. Let’s see why…

Enable CORS on Azure Functions App

The service request from our game fails because our web app is running on the https://localhost domain and our web services are running at https://simple-rpg-services.azurewebsites.net. Browsers and JavaScript engines ensure that requests are not made across domains (or origins). This was done as security fixes to ensure malicious sites were not taking users off to bad services or making requests to services that they were not given access to. Therefore, by default, browsers fail this sort of request.

But the W3C also defined a mechanism to allow this form of communication between apps and services called Cross-Origin Resource Sharing (CORS). This is an opt-in mechanism defined in the web service for known calling domains. Basically, we can say that our local domain web app is allowed to communicate with our game services.

Let’s see how we do that in our Azure Functions App:

  1. Go to the Azure Portal: https://portal.azure.com/.
  2. Navigate to the Functions App resource: ‘simple-rpg-services’.
Fig 1 – simple-rpg-services Overview
  1. In the nav bar (on the left… you may need to scroll down), click the CORS option.
Fig 2 – CORS Allowed Origins
  1. This page starts with a list of three origins that are already supported by the Azure Functions framework.
  2. In the empty line, we add our local web address: https://localhost:44370 (note: the port number may be different in your local project)
  3. Also be aware that these addresses cannot contain a trailing ‘/’ (backslash) character. If that is part of the address, the service calls will continue to fail.
  4. Click the ‘Save’ button at the top of the page for the changes to take hold.

As a side note, we can also define a wildcard (*) in the ‘Allowed Origins’ list. This would make the service callable by any other domains. It completely disables the security aspects of this scenario, so it is better to add your known origins to this list. We will need to add more allowed CORS domains when we deploy our game to Azure again.

Let’s try to run the game locally again. This time, the game should load up to our familiar screen. We can see our Pointy Stick in the weapon dropdown and items in our inventory. So we successfully retrieved those items from our game services. Let’s go kill a few snakes to make sure our game still works!

Fig 3 – Game Screen Connecting to Game Services

With our ItemTemplates loading from our game services, our game is truly online and making service calls. In the next lesson we will look at retrieving the remaining factory data from our game services.

2 thoughts on “Lesson 5.14: Use Service Client to Retrieve ItemTemplates From Game Services

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